Rough edges: mobile 100dvh, session-expiry in chat, sticky error toasts, journal date filter (#85)
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
This commit is contained in:
@@ -32,21 +32,34 @@ export const toast = {
|
||||
// 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])
|
||||
}, [item.id, dismiss, isError])
|
||||
return (
|
||||
<div
|
||||
role={item.tone === 'error' ? 'alert' : 'status'}
|
||||
role={isError ? 'alert' : 'status'}
|
||||
className={cn(
|
||||
'pointer-events-auto rounded-md border px-3 py-2 text-sm shadow-md',
|
||||
item.tone === 'error'
|
||||
'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',
|
||||
)}
|
||||
>
|
||||
{item.message}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,15 @@ export function JournalPanel({
|
||||
scopeObjectId: number | null
|
||||
onScopeChange: (id: number | null) => void
|
||||
}) {
|
||||
const filter = scopeObjectId != null ? { objectId: scopeObjectId } : {}
|
||||
// Date-range narrowing (#85): the backend and JournalFilter already supported
|
||||
// from/to; they just had no UI. Empty inputs don't filter.
|
||||
const [from, setFrom] = useState('')
|
||||
const [to, setTo] = useState('')
|
||||
const filter = {
|
||||
...(scopeObjectId != null ? { objectId: scopeObjectId } : {}),
|
||||
...(from ? { from } : {}),
|
||||
...(to ? { to } : {}),
|
||||
}
|
||||
const journal = useJournal(gardenId, filter)
|
||||
const entries = journal.data?.pages.flatMap((p) => p.entries) ?? []
|
||||
const scopedObject = objects.find((o) => o.id === scopeObjectId) ?? null
|
||||
@@ -70,6 +78,41 @@ export function JournalPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<label className="flex items-center gap-1">
|
||||
<span>From</span>
|
||||
<input
|
||||
type="date"
|
||||
value={from}
|
||||
max={to || undefined}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
className="rounded-md border border-border bg-surface px-1.5 py-1 text-fg outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<span>To</span>
|
||||
<input
|
||||
type="date"
|
||||
value={to}
|
||||
min={from || undefined}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
className="rounded-md border border-border bg-surface px-1.5 py-1 text-fg outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
/>
|
||||
</label>
|
||||
{(from || to) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFrom('')
|
||||
setTo('')
|
||||
}}
|
||||
className="rounded px-1 text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<Composer
|
||||
gardenId={gardenId}
|
||||
|
||||
@@ -160,6 +160,15 @@ export async function streamChat(
|
||||
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
|
||||
|
||||
@@ -398,8 +398,11 @@ export function GardenEditorPage() {
|
||||
})
|
||||
}
|
||||
|
||||
// 100dvh, not 100vh: on mobile Safari/Chrome 100vh is the *largest* viewport
|
||||
// (URL bar hidden), so with the bar showing the editor overflowed and pushed
|
||||
// the canvas bottom + Fit button under the browser chrome (#85).
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3 md:flex-row">
|
||||
<div className="flex h-[calc(100dvh-8rem)] flex-col gap-3 md:flex-row">
|
||||
<div className="shrink-0 md:w-40">
|
||||
<h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}>
|
||||
{garden.name}
|
||||
|
||||
Reference in New Issue
Block a user