Skip to content

Relay handoff answers home, show working channels, and open the app to the keyboard - #290

Open
guidovizoso wants to merge 9 commits into
mainfrom
guido/experience
Open

Relay handoff answers home, show working channels, and open the app to the keyboard#290
guidovizoso wants to merge 9 commits into
mainfrom
guido/experience

Conversation

@guidovizoso

Copy link
Copy Markdown
Contributor

A batch of experience work: handoff answers stop landing in a second conversation, channels show when a Bot is working in them, open transcripts pick up turns that ran with no browser attached, first sign-in gets an onboarding wizard, and Shift+N starts a chat from anywhere.

Handoff answers come home

A forward hop used to answer in the addressed Bot's own channel with the person — two conversations for one question, and the answer landed somewhere they never asked anything. Now the hop runs in a scratch thread minted per hop, never mapped to a channel and never shown, and what the Bot said is relayed back into the conversation that asked, in the asking Bot's voice, attributed. The relay rides the same durable queue as the turn that produced it, answers are clipped so a Bot that returns a book cannot swamp the relaying run's prompt, and the answerIn marker that already stopped failure notices recursing stops a relay relaying.

Because a person now waits through every leg, offering work also fires pg_notify so a sweep starts immediately instead of at its next two-second poll. The poll stays as the backstop; a lost notification costs one interval, never the work.

Channels show work happening

A transient busy flag on the activity socket — announced, never persisted. The server signals it from the runtime's thread-lock acquire/release (covering headless hops and runs whose tab navigated away), and the browser reports its own turns over a new membership-checked POST /:channelId/busy. The roster badges the channel's avatar with three bouncing dots, patching only the busy field so the preview and ordering are untouched.

An open transcript also picks up turns nobody streamed: it watches the roster's own cache, and when a channel's lastMessageAt advances to a Bot-authored moment it re-reads durable history and appends messages by id — so a relayed answer appears without leaving and coming back.

Onboarding and keyboard

  • First sign-in is gated behind a per-user onboarding wizard.
  • Shift+N starts a new chat from anywhere in the signed-in app, the new-channel composer focuses its recipient picker on arrival, and a read-only shortcut list on settings is drawn from the same registry the listeners match against.

Verification

  • bun run typecheck, biome format and lint: clean.
  • Full suite (bun run test:ci) locally: all handoff unit and integration tests pass, including the new relay, batching, and event-patch cases. (The handoff integration tests share the queue's bot.message kind with a running dev server; locally they need the dev server stopped, which is expected.)

Two columns on users say where somebody is in first-run onboarding and
when they finished; /api/me carries the status and one POST moves it.
The app redirects an unfinished user to /onboarding — an animated
three-step wizard — and an address the build does not know now goes
home through the same gate instead of a bare 404.
# Conflicts:
#	bun.lock
#	server/drizzle/meta/0015_snapshot.json
#	server/drizzle/meta/_journal.json
#	server/src/app.ts
#	server/src/index.ts
… next poll

The handoff queue was swept every two seconds, and a person is waiting through
every hop, so each leg of a handoff cost up to a full interval doing nothing.
Offering work now fires pg_notify with the kind as payload, inside the offering
transaction where there is one so it fires on commit and never before, and any
replica can listen on a dedicated connection and kick its sweep immediately.

A notification is a latency optimisation, never a delivery mechanism: one lost
in transit costs up to one poll interval, not the work, and the queue's tables
stay the only truth.
A transient busy flag on the channel activity event, announced and never
written: busy is a moment, not a fact about the channel, and a missed signal
costs at most a stuck-looking dot until the next real event, never data.

Two ways in. The server signals by thread (signalBusy) for the runs it can see
— the runtime's lock acquire and release now carry an onRunBusy seam, so every
run the platform processes lights its channel, including one whose tab has
navigated away — and a scratch thread maps to no channel and signals nowhere,
which is the point of a scratch thread. The browser signals by channel
(POST /:channelId/busy) for the one thing the server cannot see, a person's own
turn beginning, with a membership check so belonging to a channel is not
something an outsider can probe for.
A forward hop used to answer in the addressed Bot's own channel with the
person: two conversations for one question, and the answer landed somewhere
they never asked anything. Now the hop runs in a scratch thread of the
addressed Bot's own — minted per hop, never mapped to a channel, never shown —
and what it said comes home through a second queued hop that has the asking
Bot relay the answer, attributed, in the conversation the person is watching.

The delivery gathers the Bot's words from the stream as it goes past, because
the runner publishes the turn to the platform and the events are the one
chance to hear it. The relay rides the same durable queue as the turn that
produced it, so a pod dying between the two loses the relay to a retry rather
than for ever; the answerIn marker that stops a failure notice recursing stops
a relay relaying. Answers are clipped at 12k characters so a Bot that comes
back with a book cannot swamp the relaying run's prompt, and a backwards hop
reads only the tail of the asking conversation so relaying never grows slower
with the channel.

Wired in index.ts: the scratch thread replaces the direct-channel answerIn,
the roster announcement resolves thread to channel and happens only when the
turn said something, the asking channel is lit while a forward hop runs, and
the queue's new offered-work notification kicks the sweep so a person is not
waiting out a poll interval per leg.
Three bouncing dots badged on the channel's avatar, driven by the busy flag on
the activity socket. Socket-only and transient on the summary type: the roster
query never returns it, so it is undefined until a busy event arrives and drops
whenever the roster refetches — the acceptable failure for a hint about a
moment. The event patcher flips only the busy field: the spread that serves
ordinary activity would carry the event's null message onto the row and wipe
the preview, and busy is not activity, so the row does not re-sort either.

The channel reports its own turns over the new busy endpoint, fire-and-forget,
and deliberately does not clear on unmount: a turn keeps running server-side
after the person leaves the channel, and the server clears it when the run's
lock is released.
A relayed handoff answer runs on the server and lands in the thread with no
browser attached; the transcript restored history once, on mount, and would
show the new turn only after leaving and coming back. The chat now watches the
roster's own channel-list cache — the sidebar updating and the transcript
refreshing are one signal and cannot drift apart — and when this channel's
lastMessageAt advances to a moment a Bot authored, the durable history is read
again and messages whose ids the transcript has never seen are appended.
Appended by id, not compared by length, because the stored read keeps only what
the platform can parse and can be shorter than the screen while still holding
the news. Retried briefly, since the roster is patched when the turn is on
record with the runner and the platform's read can be a beat behind.

The chat also reports its own turns to the busy endpoint, keyed on whether a
turn is in flight, so the roster's working dots cover the one run the server
cannot see begin.
A small hotkey registry, one place on purpose: the binding a listener matches
against and the combo the settings page shows are the same record, so the list
under Preferences is what the keys actually do rather than what somebody
remembered they did — each key drawn as its own keycap, symbols on a Mac and
names elsewhere. Matching is exact rather than at-least, so Shift+N does not
fire on Cmd+Shift+N and shadow whatever the browser means by it, and a plain
Shift+letter combo is left alone while the focus is anywhere editable.

Bound in _authed rather than _app, so a person on settings or admin can start
a chat without first clicking back into the app frame. The new-channel
composer's recipient picker takes focus on arrival, so Shift+N then typing a
name is one motion.
The wizard shipped unformatted; biome's own output, no hand edits.

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed and driven on a real EKS deployment (single user, sandbox computers), not just read. The headline change works: I asked General Assistant to put a question to Knowledge and the answer came home into the asking conversation as "Knowledge replied: CORMORANT", attributed, under the ▸ Asked Knowledge marker. Confirmed in a fresh channel and in a 65-message one, and confirmed durable by reading the thread back from the platform rather than trusting the screen.

Also verified on the deployment: the busy badge appears on the roster avatar while a hop runs and clears afterwards; the roster bumps and previews the relayed answer; the onboarding gate redirects to /onboarding and holds against a direct navigation to /admin/audit; migration 0025 correctly stamps an existing user as onboarded, so an upgrade shows no wizard (checked in the database); and Shift+N works while correctly not firing from inside an input, which is the part that matters for a plain Shift+letter combo.

Suite green here too: 2067 tests, typecheck and lint clean.

What I would fix before merging

No changelog entry, and this reverses what 0.0.5 shipped saying. This is the one I would hold on. The released 0.0.5 notes say, in these words:

The asking Bot does not relay text on its behalf, so what you read is the answer that Bot actually gave rather than another Bot's summary of it.

This PR makes the asking Bot relay exactly that, as a summary, in its own voice. Somebody who read the 0.0.5 notes and upgrades gets the opposite of what they were told. There are five user-visible changes in here (relay, busy channels, transcript catch-up, onboarding, Shift+N) and no entry for any of them, so the fix is an Unreleased section that also corrects the 0.0.5 paragraph rather than leaving it standing.

app/src/routes/_authed/onboarding.tsx:104 has max-w-lgoverflow-hidden. Two classes run together, so neither max-w-lg nor overflow-hidden applies. It is visible: the roster cards render wider than every other step of the wizard.

Lower confidence, worth a look

  • Empty roster after finishing onboarding. I landed at / with no channels in the sidebar. The API had all five, and a reload restored them, so nothing was lost. Seen once and I could not cleanly re-run it, so a lead rather than a confirmed defect. invalidateCurrentUser only invalidates the current-user key, which may be the thread to pull.
  • One relay rendered truncated. In the long channel the transcript showed Knowledge: ALBAT and stayed that way, while the stored message was the complete Knowledge replied: ALBATROSS. Render only, and it did not reproduce in a fresh channel.
  • onboarding_step is currently dead weight. The column, the migration, setStep, the endpoint field and advanceOnboardingMutationOptions are all unused. onboarding.tsx:146 says that is deliberate while the wizard is being designed, which is fair, but people/onboarding.ts documents step as "where the wizard resumes if they left halfway through" and it never resumes. I watched it stay 0 through a full run. Either drop the claim from that comment or wire it up.
  • Placeholder agents are indistinguishable from real ones. "Support Agent" sat beside General Assistant and Knowledge in identical styling on a deployment that has no such Bot. The comment says the invented names illustrate a roster "without claiming any of these exist here", but nothing on screen marks them as illustrations, so it does read as a claim.

One note on my own testing

My browser harness cannot deliver a shift-modified keystroke (pressing it into a focused textarea typed nothing), so Shift+N looked broken twice before I dispatched a real KeyboardEvent and saw it navigate. Flagging it so nobody repeats the false alarm.

Design reading: relaying through the queue with answerIn doing double duty as the loop guard, the answer clipped, and a relay failure logged rather than failing the hop into a second turn, all look right to me. The scratch thread explanation for why the addressed Bot cannot speak in the asking thread is convincing, and the busy signal covering only the forward leg (because the backwards leg already lights through the thread lock) is the detail I would have got wrong.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants