KQ-1294 · THREAD/VIEW

Thread View

A complete assistant thread over a tree of messages: runs from one author share an avatar, each message's toolbar rises into place on snap, copy morphs into a check on flick and a reaction lands on recoil. Editing a sent message forks the thread, and the version switcher slides the message and every reply under it — the old branch out on the exit ease, the new one in on glide — while its counter rolls on snap and the edited bubble's measured height glides. Scrolled up, a pill counts the replies that land and glides the log to the newest on glide; End does the same, Enter sends or saves, Shift+Enter breaks a line and Escape cancels an edit.

KQ-1294 · THREAD/VIEW

Basin export retries

Fernworks Model 3
  1. Rae Okafor, 8 min ago:

    Basin export retries pile up after an upload outage and swamp the bucket the moment it recovers. How should the uploader back off?

  2. Fernworks Model 3, 7 min ago:

    Back off exponentially, cap the wait, and add full jitter, so the retries from one outage do not all land in the same second.

    ts
    const base = 500; // ms
    const cap = 30_000;
    
    function delay(attempt: number, rand: () => number) {
      const ceiling = Math.min(cap, base * 2 ** attempt);
      return Math.round(rand() * ceiling);
    }

    With full jitter the herd spreads across the whole window instead of arriving in waves.

  3. Rae Okafor, 2 min ago:

    Does the cap still matter if we also limit attempts?

  4. Rae Okafor, 2 min ago:

    We give up after six tries today.

  5. Fernworks Model 3, 1 min ago:

    Yes. The attempt limit bounds how long you keep trying; the cap bounds the longest single wait. Without one, attempt 6 waits up to 32 s and attempt 8 over two minutes.

    • Keep the cap near your upload timeout.
    • Count attempts per file, not per job.
  6. Fernworks Model 3, just now:

    If the bucket answers 429, honour its retry hint before your own delay:

    ts
    const hint = Number(res.headers.get("retry-after") ?? 0) * 1000;
    await sleep(Math.max(hint, delay(attempt, rand)));

3 versions of the first turn · hover a message for its tools

Install

One command — the source lands in your repo. Or copy it from the Code tab.

$ pnpm dlx shadcn@latest add @kinetiq/thread-view

Props

messages / defaultMessages
ThreadMessage[]
default defaultThreadMessages
Every version of every turn as one flat list: messages that share a parentId are versions of one turn. Controlled or uncontrolled.
onMessagesChange
(messages: ThreadMessage[]) => void
default —
Fires from the send, edit or reaction that changed the list, with the whole new list.
leaf / defaultLeaf
string
default —
Any message on the branch to show. The thread runs from the first turn through it, then on down the newest replies. Defaults to the newest version of every turn.
onLeafChange
(leaf: string) => void
default —
Fires when a version switch, a send or an edit moves the visible branch.
density
"compact" | "cozy" | "roomy"
default "cozy"
How tightly the thread is set: type size, bubble padding, avatar size and the gap between runs.
branches
boolean
default true
Edits keep every version, with a switcher to move between them. Off: the data still forks, but an edit reads as a replacement.
toolbar
"hover" | "always" | "off"
default "hover"
Each message's actions: a pill on hover, focus or tap, a row always shown, or none for a read-only transcript.
onSend
(text: string, message: ThreadMessage) => void
default —
A message was sent from the composer. It is already in the list; stream the reply in as a child of message.id.
onEdit
(original: ThreadMessage, version: ThreadMessage) => void
default —
A message was edited: version is the new sibling of original and the branch now shows it.
onCopy
(text: string, messageId: string) => void
default —
A message or one of its code blocks was copied to the clipboard.
onReact
(messageId: string, reaction: "up" | "down" | null) => void
default —
A reply was marked good or bad, or the mark was cleared.
onRetry
(messageId: string) => void
default —
Retry was pressed on a reply whose status is "error".
onReload
() => void
default —
Try again was pressed while status is "error".
now
number | Date
default —
The moment relative times count from. Defaults to the newest message's time, so the thread renders the same on the server and in the browser.
author
string
default —
The name new messages are sent under. Defaults to the newest user message's author, else "You".
title
string
default "Basin export retries"
The thread's name in the header; also its accessible name unless label is given.
model
string
default "Fernworks Model 3"
The model answering, shown in the header and the composer's placeholder.
status
"ready" | "loading" | "error"
default "ready"
Whether the thread's data has arrived: a skeleton while loading, a message with Try again on error.
composer
boolean
default true
Show the composer: a textarea that grows to five lines, Enter to send, Shift+Enter for a new line.
placeholder
string
default —
The composer's placeholder. Defaults to "Message" and the model's name.
suggestions
string[]
default —
Prompts offered while the thread is empty; pressing one fills the composer.
label
string
default —
The thread's accessible name. Defaults to the title.
sound
boolean
default false
A pop for a send, a saved edit or a reaction; a tick for a version switch, a copy and the jump to the latest.
disabled
boolean
default false
Read only: nothing can be sent, edited or reacted to.
className
string
default —
Classes for the root. It is 560px tall by default; pass a height class to change it.