Skip to content

Replace custom tool-step & reasoning UI with shadcn/ui Marker component #669

Description

@SvenVw

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

  • Marker component is installed via pnpm dlx shadcn@latest add marker in fdm-app.
  • <Progress> bar is removed from GerritLoading.
  • Static PHASES array and computePhases function are removed.
  • deriveTimeline replaces deriveState + computePhases as the single derivation function.
  • Timeline feed renders one <Marker> per distinct event type as it arrives — no upfront pending rows.
  • Tool entries use <Marker variant="status"> with spinner while running, check icon when done.
  • Repeated tool calls increment <MarkerBadge>×N</MarkerBadge> in-place rather than adding a new row.
  • start / status messages render as <Marker variant="labeled-separator">.
  • Reasoning renders inline in the feed as <Marker variant="note"> with the last non-empty line as preview.
  • Timeline auto-scrolls to the bottom as new entries arrive.
  • "Voorbereiden…" placeholder shown when the feed is empty.
  • ClarifyLoading reasoning preview uses <Marker variant="note">.
  • All ad-hoc flex items-center gap-3 rounded-md … styling is removed from both files.
  • No raw dark: colour overrides — semantic tokens only.
  • Type-check passes: pnpm check-types (runs react-router typegen && tsc).

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions