Summary
The Gerrit loading screens (GerritLoading and ClarifyLoading) currently use a static 7-phase
checklist driven by a computePhases helper, a <Progress> bar, and a separate
<Collapsible> for reasoning. The whole thing is built from hand-rolled <li> and <div>
elements.
This issue replaces all of that with a live event timeline — a growing feed of Marker entries
that appear in real time as stream events arrive. The progress bar and the static phase list are
removed entirely.
shadcn/ui released the Marker component in June 2026 as part of their official chat-interface
primitives (MessageScroller, Message, Bubble, Attachment, Marker). It is designed for
exactly this use-case: "Displays an inline status, system note, bordered row, or labeled separator
in a conversation."
Why a timeline instead of a checklist?
| Checklist (current) |
Timeline (proposed) |
| Fixed 7 rows — always visible, even before they're relevant |
Rows appear only when an event actually fires |
| Pending phases clutter the view |
No pending noise — the feed is always current |
Requires computePhases to map tools → phases |
Direct: each raw event maps to a Marker entry |
| Progress bar summarises what the list already shows |
Redundant — removed |
| Reasoning is a separate, disconnected collapsible |
Reasoning appears inline in the timeline where it happens |
Relevant files
| File |
What changes |
fdm-app/app/components/blocks/gerrit/loading.tsx |
Full rewrite: replace progress bar + phase checklist + reasoning collapsible with deriveTimeline + Marker feed |
fdm-app/app/components/blocks/gerrit/clarify-loading.tsx |
Replace reasoning preview <div> with <Marker variant="note"> |
fdm-app/app/routes/farm.$b_id_farm.$calendar.gerrit.tsx |
No logic changes — component interface stays the same |
What Marker provides
Reference: https://ui.shadcn.com/docs/components/radix/marker
Changelog: https://ui.shadcn.com/docs/changelog/2026-06-chat-components
Marker is a composable primitive with four variants:
| Variant |
Renders |
Maps to timeline entry |
status |
Icon + label row, with active/done states |
Tool call (on_tool_start / on_tool_end) |
note |
Muted aside row |
Reasoning burst (reasoning chunks) |
labeled-separator |
Horizontal rule with centred text |
start / status / finalize events |
bordered-row |
Full-width bordered container |
Expanded reasoning body |
Sub-components used:
<Marker variant="status">
<MarkerIcon><Spinner /></MarkerIcon> {/* or <Check />, etc. */}
<MarkerContent>Meststoffen zoeken</MarkerContent>
<MarkerBadge>×3</MarkerBadge> {/* repeat-count, optional */}
</Marker>
<Marker variant="note">
<MarkerIcon><Sparkles /></MarkerIcon>
<MarkerContent>Gerrit denkt na…</MarkerContent>
</Marker>
<Marker variant="labeled-separator">
<MarkerContent>Gestart</MarkerContent>
</Marker>
Proposed changes
1 — Install the component
cd fdm-app
pnpm dlx shadcn@latest add marker
This adds fdm-app/app/components/ui/marker.tsx (source-owned, no external dep).
2 — New data model: TimelineEntry
Replace PHASES, computePhases, and deriveState with a single deriveTimeline function that
builds an ordered list of entries directly from the raw stream events.
// Tool name → Dutch label (replaces the PHASES array for label lookup only)
const TOOL_LABELS: Record<string, string> = {
getFarmFields: "Gegevens verzamelen",
getCropFertilizerGuide: "Teelthandleiding raadplegen",
getFarmNutrientAdvice: "Bemestingsadvies ophalen",
getFarmLegalNorms: "Wettelijke normen berekenen",
searchFertilizers: "Meststoffen zoeken",
simulateFarmPlan: "Plan doorrekenen",
}
type TimelineEntry =
| { kind: "separator"; id: string; label: string }
| { kind: "tool"; id: string; toolName: string; label: string; status: "running" | "done"; count: number }
| { kind: "reasoning"; id: string; text: string; isActive: boolean }
function deriveTimeline(events: StreamEvent[]): TimelineEntry[] {
const entries: TimelineEntry[] = []
// Track insertion order for tools and the current reasoning entry
const toolIndex = new Map<string, number>() // toolName → index in entries
let reasoningIndex = -1
for (const event of events) {
if (event.type === "start" || event.type === "status") {
const label = event.data?.message
if (label) {
entries.push({ kind: "separator", id: `sep-${entries.length}`, label })
}
} else if (event.type === "on_tool_start") {
const name = event.data?.name
if (!name) continue
const existing = toolIndex.get(name)
if (existing !== undefined) {
// Tool fired again — increment counter, reset to running
const entry = entries[existing] as Extract<TimelineEntry, { kind: "tool" }>
entry.count += 1
entry.status = "running"
} else {
toolIndex.set(name, entries.length)
entries.push({
kind: "tool",
id: `tool-${name}`,
toolName: name,
label: TOOL_LABELS[name] ?? name,
status: "running",
count: 1,
})
}
} else if (event.type === "on_tool_end") {
const name = event.data?.name
if (!name) continue
const idx = toolIndex.get(name)
if (idx !== undefined) {
(entries[idx] as Extract<TimelineEntry, { kind: "tool" }>).status = "done"
}
} else if (event.type === "reasoning") {
const chunk: string = event.data?.chunk ?? ""
if (!chunk) continue
if (reasoningIndex === -1) {
// First reasoning chunk — insert a new note entry at current position
reasoningIndex = entries.length
entries.push({ kind: "reasoning", id: "reasoning", text: chunk, isActive: true })
} else {
(entries[reasoningIndex] as Extract<TimelineEntry, { kind: "reasoning" }>).text += chunk
}
}
}
// Mark reasoning inactive once any tool fires after it, or on finalize
const lastToolIdx = Math.max(...toolIndex.values(), -1)
if (reasoningIndex !== -1 && lastToolIdx > reasoningIndex) {
(entries[reasoningIndex] as Extract<TimelineEntry, { kind: "reasoning" }>).isActive = false
}
return entries
}
Note: deriveTimeline is a pure function — it replaces both deriveState and
computePhases. The existing useMemo call stays, just pointing at the new function.
3 — Rewrite GerritLoading render
Remove: <Progress>, the <ol> phase checklist, the <Collapsible> reasoning block, all
imports for Progress, Collapsible, CollapsibleContent, CollapsibleTrigger, Circle,
ChevronDown.
Remove state: reasoningOpen, progressValue, doneCount, isThinking, reasoningPreview.
Keep: CardHeader with elapsed timer, CardContent wrapper.
export function GerritLoading({ events = [] }: { events?: StreamEvent[] }) {
const [elapsed, setElapsed] = useState(0)
const startRef = useRef(Date.now())
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const id = setInterval(
() => setElapsed(Math.floor((Date.now() - startRef.current) / 1000)),
1000,
)
return () => clearInterval(id)
}, [])
const timeline = useMemo(() => deriveTimeline(events), [events])
// Auto-scroll to the latest entry
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" })
}, [timeline.length])
const elapsedStr = elapsed >= 60 ? `${Math.floor(elapsed / 60)}m ${elapsed % 60}s` : `${elapsed}s`
return (
<Card className="flex flex-col shadow-sm">
<CardHeader className="shrink-0 border-b">
<CardTitle className="flex items-center justify-between text-base font-semibold">
<span className="flex items-center gap-2">
<Bot className="text-primary size-5 animate-pulse" />
Gerrit is aan het werk…
</span>
<span className="text-muted-foreground text-sm font-normal tabular-nums">
{elapsedStr}
</span>
</CardTitle>
</CardHeader>
<CardContent className="p-4">
<div aria-live="polite" className="flex flex-col gap-0.5">
{timeline.length === 0 && (
<Marker variant="note">
<MarkerIcon><Spinner /></MarkerIcon>
<MarkerContent>Voorbereiden…</MarkerContent>
</Marker>
)}
{timeline.map((entry) => {
if (entry.kind === "separator") {
return (
<Marker key={entry.id} variant="labeled-separator">
<MarkerContent>{entry.label}</MarkerContent>
</Marker>
)
}
if (entry.kind === "tool") {
return (
<Marker key={entry.id} variant="status">
<MarkerIcon>
{entry.status === "running" ? <Spinner /> : <Check />}
</MarkerIcon>
<MarkerContent>{entry.label}</MarkerContent>
{entry.count > 1 && <MarkerBadge>×{entry.count}</MarkerBadge>}
</Marker>
)
}
if (entry.kind === "reasoning") {
return (
<Marker key={entry.id} variant="note">
<MarkerIcon>
<Sparkles className={cn(entry.isActive && "animate-pulse")} />
</MarkerIcon>
<MarkerContent className="line-clamp-2 italic">
{entry.text
.trim()
.split("\n")
.map((l) => l.replace(/[*#`]/g, "").trim())
.filter(Boolean)
.pop()}
</MarkerContent>
</Marker>
)
}
})}
<div ref={bottomRef} />
</div>
</CardContent>
</Card>
)
}
4 — Rewrite ClarifyLoading reasoning preview
The same <Marker variant="note"> pattern replaces the bespoke styled div:
{preview && (
<Marker variant="note">
<MarkerIcon>
<Sparkles className="animate-pulse" />
</MarkerIcon>
<MarkerContent className="line-clamp-2 italic">{preview}</MarkerContent>
</Marker>
)}
5 — Expandable reasoning (optional enhancement)
If full reasoning text is desired (the current collapsible behaviour), wrap entry.kind === "reasoning" in a Collapsible or use the Marker collapsible prop (verify after install):
<Collapsible>
<CollapsibleTrigger asChild>
<Marker variant="note" className="cursor-pointer">
<MarkerIcon><Sparkles className={cn(entry.isActive && "animate-pulse")} /></MarkerIcon>
<MarkerContent className="italic">{previewLine}</MarkerContent>
</Marker>
</CollapsibleTrigger>
<CollapsibleContent>
<Marker variant="bordered-row">
<MarkerContent className="max-h-48 overflow-y-auto text-sm whitespace-pre-wrap">
{entry.text.trim()}
</MarkerContent>
</Marker>
</CollapsibleContent>
</Collapsible>
Acceptance criteria
Out of scope
- Replacing the
<Card> / <CardHeader> outer shells.
- Changes to the stream API or server-side event shape.
- Full reasoning expand/collapse (nice-to-have, covered in step 5 above).
References
Summary
The Gerrit loading screens (
GerritLoadingandClarifyLoading) currently use a static 7-phasechecklist driven by a
computePhaseshelper, a<Progress>bar, and a separate<Collapsible>for reasoning. The whole thing is built from hand-rolled<li>and<div>elements.
This issue replaces all of that with a live event timeline — a growing feed of
Markerentriesthat appear in real time as stream events arrive. The progress bar and the static phase list are
removed entirely.
shadcn/ui released the
Markercomponent in June 2026 as part of their official chat-interfaceprimitives (
MessageScroller,Message,Bubble,Attachment,Marker). It is designed forexactly this use-case: "Displays an inline status, system note, bordered row, or labeled separator
in a conversation."
Why a timeline instead of a checklist?
computePhasesto map tools → phasesRelevant files
fdm-app/app/components/blocks/gerrit/loading.tsxderiveTimeline+ Marker feedfdm-app/app/components/blocks/gerrit/clarify-loading.tsx<div>with<Marker variant="note">fdm-app/app/routes/farm.$b_id_farm.$calendar.gerrit.tsxWhat
MarkerprovidesMarkeris a composable primitive with four variants:statuson_tool_start/on_tool_end)notereasoningchunks)labeled-separatorstart/status/ finalize eventsbordered-rowSub-components used:
Proposed changes
1 — Install the component
cd fdm-app pnpm dlx shadcn@latest add markerThis adds
fdm-app/app/components/ui/marker.tsx(source-owned, no external dep).2 — New data model:
TimelineEntryReplace
PHASES,computePhases, andderiveStatewith a singlederiveTimelinefunction thatbuilds an ordered list of entries directly from the raw stream events.
3 — Rewrite
GerritLoadingrenderRemove:
<Progress>, the<ol>phase checklist, the<Collapsible>reasoning block, allimports for
Progress,Collapsible,CollapsibleContent,CollapsibleTrigger,Circle,ChevronDown.Remove state:
reasoningOpen,progressValue,doneCount,isThinking,reasoningPreview.Keep:
CardHeaderwith elapsed timer,CardContentwrapper.4 — Rewrite
ClarifyLoadingreasoning previewThe same
<Marker variant="note">pattern replaces the bespoke styled div:5 — Expandable reasoning (optional enhancement)
If full reasoning text is desired (the current collapsible behaviour), wrap
entry.kind === "reasoning"in aCollapsibleor use theMarkercollapsible prop (verify after install):Acceptance criteria
Markercomponent is installed viapnpm dlx shadcn@latest add markerinfdm-app.<Progress>bar is removed fromGerritLoading.PHASESarray andcomputePhasesfunction are removed.deriveTimelinereplacesderiveState+computePhasesas the single derivation function.<Marker>per distinct event type as it arrives — no upfront pending rows.<Marker variant="status">with spinner while running, check icon when done.<MarkerBadge>×N</MarkerBadge>in-place rather than adding a new row.start/statusmessages render as<Marker variant="labeled-separator">.<Marker variant="note">with the last non-empty line as preview.ClarifyLoadingreasoning preview uses<Marker variant="note">.flex items-center gap-3 rounded-md …styling is removed from both files.dark:colour overrides — semantic tokens only.pnpm check-types(runsreact-router typegen && tsc).Out of scope
<Card>/<CardHeader>outer shells.References
https://ui.shadcn.com/docs/components/radix/marker— Marker docshttps://ui.shadcn.com/docs/changelog/2026-06-chat-components— Chat components release notesfdm-app/app/components/blocks/gerrit/loading.tsx— currentGerritLoadingsourcefdm-app/app/components/blocks/gerrit/clarify-loading.tsx— currentClarifyLoadingsource