Skip to content

Thread outbox: UI polish, queue images, send-and-interrupt — and decide whether the queue should survive #35

Description

@radroid

Follow-up on the thread outbox (queue-while-running) feature from #26. Three concrete gaps plus one
design decision that should be settled first, because it changes how much of the rest is worth
building.


0. The decision: does the queue earn its keep?

The queue exists because you can't talk to a running turn. The alternative is send-and-interrupt:
type, hit send, the current turn is interrupted and your message starts the next one immediately.
That is what most agent UIs do, and it is what people reach for reflexively.

I checked whether upstream already covers this — it does not:

// packages/contracts/src/provider.ts:87
export const ProviderInterruptTurnInput = Schema.Struct({
  threadId: ThreadId,
  turnId: Schema.optional(TurnId),
});

Interrupt is a pure stop with no message payload, so there is no upstream path that both stops the
turn and submits text. The queue is not redundant today.

But if we add send-and-interrupt, the queue's genuinely unique value shrinks to:

  • Deferred send — "finish what you're doing, then do this" (interrupting would discard in-flight work)
  • Disconnected buffering — composing while the backend is unreachable
  • Survives reload — localStorage-backed, so a refresh doesn't lose the message

Those are real, but they are a much smaller feature than what exists now. Decide first: keep the
queue as the primary mid-turn path, demote it to a secondary "send after this turn" affordance behind
send-and-interrupt, or remove it. Everything below assumes it survives in some form.


1. The queue UI is visually heavy

From the current build (screenshot in thread):

  • The queued-message list renders as a full-width bordered card stacked on a bordered composer,
    producing a heavy double-border container that dominates the bottom of the thread.
  • A one-line message gets a card with card-sized vertical padding. With 3-4 queued messages this
    pushes the composer far up the viewport.
  • QUEUED MESSAGE · 1 sits as a caption colliding with the container's top border.
  • Edit (pencil) and delete (trash) icons are pinned to the far right, visually detached from the text
    they act on, and are always visible rather than on hover/focus.
  • The footer now carries three competing controls — a progress spinner, a saturated red stop
    button, and a blue "Queue" button. The red is the highest-contrast element in the composer despite
    being the secondary action.
  • Nothing states the contract: that these send automatically when the turn finishes.
  • No reordering. removeThreadOutboxMessage exists but there is no move-up/move-down or drag.

Suggested direction: collapse each queued item to a single dense row (one line, truncated, hover-
revealed actions), drop the nested border so the queue reads as part of the composer chrome rather
than a second panel, and add a short "Sends when this turn finishes" hint.

2. Images can't be queued

Reproducer: attach an image while a turn is running, add text, press Queue. The text queues, the image
is dropped, and you get a warning toast:

Images aren't queued — Queued messages are text-only; attached images were not added to the
queued message.

Image-only (no text) is refused outright with a similar toast.

The current behaviour is deliberate and correctly implemented — the code comments in
ChatView.tsx explain the reasoning, and the toast fires only after a successful enqueue so a failed
enqueue never misreports. The limitation is the storage layer:

// apps/web/src/outbox/threadOutboxStorage.ts
// localStorage-backed persistence for the thread outbox ...

localStorage is string-only with a ~5MB quota, so blob-backed image previews cannot survive a
reload. Queuing images needs the outbox moved to IndexedDB (stores Blobs natively, far larger
quota), with the queued item holding a blob reference and the drain rehydrating it at send time.
Needs a migration path for messages already in localStorage, and a quota/eviction story.

Note this also affects the mobile outbox, which mirrors the same design.

3. Send-and-interrupt

Add a way to stop the running turn and immediately send the composed message. Requires a contract
change — either a new RPC or an optional message payload on the interrupt input, since the current
one carries none. Should be explicit rather than the default Enter behaviour, so nobody discards a
long-running turn by muscle memory.


Suggested order

  1. Settle §0 — it determines whether §1 is polish or removal.
  2. §3 send-and-interrupt (contract change, highest user value).
  3. §1 UI polish, scoped by the §0 outcome.
  4. §2 images — largest change (storage migration), lowest urgency given the toast is honest today.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions