Skip to content

web-ui: in-document panes with a byte-budgeted transcript, plus the stacked UX and steering fixes - #175

Merged
ReganBell merged 18 commits into
mainfrom
up-webui
Aug 4, 2026
Merged

web-ui: in-document panes with a byte-budgeted transcript, plus the stacked UX and steering fixes#175
ReganBell merged 18 commits into
mainfrom
up-webui

Conversation

@ReganBell

@ReganBell ReganBell commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

The web-ui bundle: a structural perf rework plus the UX and steering fixes stacked on it.

Core rework — one app with in-document panes, and a byte-budgeted transcript. Split-canvas panes were separate iframe apps; they're now conversation surfaces inside one document (no embed protocol, no postMessage relay, hidden tabs render nothing until shown). The transcript wire format is no longer the storage format: windows are bounded in bytes as well as turns, oversized tool payloads ship as previews, and GET /v1/sessions/:id/entries/:seq fetches a full body on expand. On a nine-pane canvas (throttled 4x CPU / 40ms RTT): visually settled 11.1s → 1.4s, heap 130MB → 38MB, 9 iframes → 0.

Hardening on top of it: cross-pane state leaks closed (runtime config, composer, canvas guards), transcript-cap edge cases, pane composer focus, pane height chain, re-attach keeping already-shown assistant text visible.

Steering: a steer typed before the run slot goes live is held and settled instead of dropped; a steered message whose run has ended is resent as an ordinary prompt; the new-chat title polls from turn start.

UX: crons page links and flattened run list; a "Request access" button on an app's not-shared page; sidebar session rename on double-click and hover-archive; icon-only background chip with tooltip; attaching files while a turn runs; a scope's default model settable from its project page; friendly Slack headers with project deep links; group DMs get a pretty project path; plus ten papercut fixes (button icon layout, modal centering, three-state theme toggle, collapsed-rail icon nav, and friends).

483 web-ui tests and the touched core suites pass; typechecks clean across all three web-ui tsconfigs.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

ReganBell and others added 18 commits August 3, 2026 23:17
…the run list

* fix(web-ui): make cron links real links, and flatten the run list

The crons page had no addressable detail view: a cron opened only as
in-page state, and the "Open worklog" link on a run pointed at
`<current path>?session=<id>`. On /crons that resolves to the crons
view with the session silently ignored, so the link did nothing.

Crons are now path-addressed as /crons/<id>: the row is a real anchor
(cmd-click and copy-link work), the URL follows the open cron, and a
pasted /crons/<id> boots straight into that cron — with a plain notice
on the list when the id is unknown. Worklog links now build the chats
deep link (/?session=<id>) through deepLinkPath instead of splicing
the current path, so they land on the conversation.

Run history drops the card-per-run layout for one line per run:
status, time, error or reply excerpt, worklog link.

* fix(web-ui): address review — history, load errors, notice styling

Review of the first commit found four real defects, fixed here:

- A failed /api/crons load reported "That cron wasn't found, or you
  don't have access to it." and hid the actual error, because the
  not-found notice occupied the row slot the error text renders in.
  The notice is now gated on the load having succeeded.
- A deep-linked cron id could survive a mid-load view switch and open
  itself minutes later; it is consumed before the view guard.
- Row anchors promised Back but replaceState broke it. Opening a cron
  from the list now pushes history, and a popstate listener routes the
  crons view back to the list or into a cron.
- The notice used a class with no styling; it reuses .action-notice.

Also: alt+click ("save link as") falls through to the browser, and
deepLinkPath throws on an item id for a view addressed by session or
scope rather than silently dropping it. Tests cover the worklog href,
the row anchor, modified-click fallthrough, history behavior, the
load-error path, and the pending-id lifecycle.

* docs: PR screenshots for the crons page change
* feat(deploy): 'Request access' button on the not-shared gate page

A signed-in visitor who lands on an app that isn't shared with them can
now click Request access; the gateway DMs the app's owner (personal home
scope, else creator) naming the visitor and the app, deduped per
visitor+app+day via the delivery outbox idempotency key. Signed-out
visitors still get the sign-in bounce and cannot post requests.

* test: non-null assertions for indexed delivery reads

---------
…nscript

* perf(web-ui): one app with in-document panes, and a byte-budgeted transcript

Loading a nine-pane Split Canvas flickered for ~10s before settling. Two roots,
both structural.

The canvas multiplied the *application* instead of the conversation. `chatState`
is a singleton, so the earlier iframe change gave each pane its own same-origin iframe rather than
refactor it — and same-origin iframes share one renderer process and one event
loop, so nine SPA boots buy no parallelism, only nine copies of the fixed cost:
bundle parse, `/me`, runtime-config, `/api/sessions` (twice each), `/api/contexts`,
and a live app behind every tab the viewer cannot see. Under that load dockview's
`always` renderer could not keep its absolutely-positioned pane overlays placed,
which is what visibly fluttered.

`chat.ts` and `composer.ts` now expose `createChatSurface` / `createComposerSurface`
factories; `conversations.ts` wires a pair into a Conversation and owns the registry,
the single delivery stream, and the full-screen conversation. Panes mount a
Conversation into their own element, so embed mode, its postMessage protocol, and
the delivery relay are gone, hidden tabs render nothing until shown, and pane state
is read rather than messaged. Most of this diff is the indentation of wrapping two
modules in a factory — `git diff -w` is 1.1k lines, not 3.5k.

Second root: the transcript wire format is the storage format. `windowedTranscript`
bounded the window in turns, a storage concept, so `tailTurns=25` shipped whole
`tool_result` bodies — 8 MB for one large session, 94% of it output the UI renders
collapsed, refetched in full every time a turn settled. It now also bounds bytes and
previews oversized tool payloads, with `GET /v1/sessions/:id/entries/:seq` fetching a
full body when someone expands it.

Measured on a nine-pane canvas against a stub core holding realistic transcripts,
throttled 4x CPU / 40ms RTT: visually settled 11.1s -> 1.4s, 12 repaints -> 4, one
463ms window where the page could not paint at all -> none, heap 130MB -> 38MB,
9 iframes -> 0, and 4 panes mounted instead of 9.

* fix(web-ui): close the cross-pane and transcript-cap holes an adversarial pass found

Nine follow-up fixes to the first commit, every one a
consequence of the two structural changes.

Panes are conversations in one realm now, so anything left at module scope is
shared by every pane. The runtime config was: `activeModelOptions`,
`defaultRuntimeValue`, and the fast-mode id set were global, so with panes on
different scopes the last runtime fetch decided every pane's picker, default
model, and fast-mode eligibility — a pane could hold model A while its turn
carried harness B. All three are keyed by scope now, and a test pins the
isolation.

The transcript cap leaked into paths that were not asking for a window:

- `tool_call` payloads with `action: "post"` carry the agent's own reply, which
  the client promotes into the visible assistant message. Truncating them cut
  posted replies to 2 KB with no way to see the rest. Post text is conversation
  text and is now exempt.
- Fork cutoffs count user entries in an unwindowed read, so a capped read forked
  after the wrong message. The byte budget now applies only when a window was
  requested; an unwindowed read still owes the caller every entry.
- A refresh that trimmed the front of its window left `transcriptAnchorSeq`
  pointing at an entry no longer in the page, so "Show earlier" skipped the gap.
  The anchor follows the page.
- A read-only conversation refreshed on delivery through the unwindowed endpoint
  and dropped `earlierEntries`, losing its pagination button.
- Strings below the depth-8 walk were neither truncated nor counted (charged 8
  bytes), so nested JSON could smuggle a megabyte past the cap.
- The byte cut landed anywhere, splitting a tool call from its result across
  pages; it snaps to a turn boundary like the turn cut already did.

And three lifecycle gaps: sign-out reset only the main conversation while pane
agents, timers, and registrations kept running; `sessionsReady()` never settled
when the first session-list fetch failed, hanging restored panes on a spinner
forever; and a pane closed mid-load could remount an agent into detached DOM.

Re-measured on the same nine-pane canvas, throttled 4x CPU / 40ms RTT:
settled 11.5s -> 1.6s, 12 repaints -> 3, 871ms frozen -> none, heap 127MB -> 38MB.

* fix(web-ui): a live run belongs to one conversation, and a refresh never shrinks the window

Three more review findings, all cross-pane consequences of mounting
conversations in one realm.

`liveRun` was a module singleton in core-bridge, so a second pane's turn
overwrote the first: Stop or Steer in one pane signalled another pane's run.
Each conversation owns a `RunSlot` now, threaded from the stream factories down
to `followRun`, and the composer signals its own chat surface instead of a
global.

The byte budget applied to `sinceSeq` reads too. That path re-reads a window the
client already holds and replaces the rendered messages with it, so trimming it
made messages the reader was looking at disappear after every settled turn. A
`sinceSeq` read is a refresh, not a page: it is never trimmed. `tailTurns` and
`beforeSeq` still are.

`renderList()` ran on every agent subscription event, including stream deltas, so
each token rebuilt the session sidebar and notified every canvas header. With one
conversation that was waste; with nine it is the load cost this PR set out to
remove. It runs on `agent_end` and on working-state flips only.

* fix(web-ui): a read-only transcript redraws, and keeps the pages it loaded

Two more review findings, both in the read-only path the previous commit
touched.

`loadFullEntry` redrew through `drawActiveChat`, which returns early when there
is no agent or host — exactly the state a read-only mount leaves behind. So in a
Slack transcript "Show full output" fetched the entry and changed nothing on
screen. It redraws through whichever surface is mounted now.

The read-only delivery refresh always refetched the tail, so a nudge threw away
history the reader had opened with "Show earlier messages" and snapped them back
to the bottom. The read-only view remembers its anchor and refreshes from it —
a `sinceSeq` read, which is never trimmed.

---------
…he iframes

The multi-view change mounts a conversation's .custom-chat directly
into .split-pane-chat, but that wrapper was display: block — a leftover from
when it styled a replaced iframe whose own document supplied html/body height.
.custom-chat sizes itself with flex: 1 / min-height: 0, which is meaningless
inside a block parent, so a pane's conversation collapsed to content height:
the transcript never scroll-contained and the composer trailed the last
message (mid-pane on short chats, clipped past the pane on long ones).

Make .split-pane-chat the same flex column the full-screen .main already is,
so panes and full screen give the conversation one height chain.
…lack's description honest

* feat(web-ui): set a scope's default model from its project page, and keep Slack's description honest

A scope's model default could only be changed from inside a chat ("Make
default" next to the composer's picker) or by an admin in Governance, so the
project page — the place members go to configure the project — could not say
which model the project runs on, let alone change it.

The page now carries a Model panel: the org default or any approved model, saved
through the same /v1/runtime-config endpoint the composer writes, so the two can
never disagree.

Slack's channel description names that model, and it was only rewritten when a
message arrived: the header ensurer hangs off inbound Slack traffic, and a
default changed anywhere else (this page, an admin, the agent) left the
description naming the old model until someone next posted. The config store now
announces a scope's model change, the Slack plugin re-ensures that channel's
header at once, and the header reads "<agent> Model: <model> · <project page>" so
a workspace running more than one agent can tell them apart.

* fix(slack): a model change landing mid-ensure re-runs instead of being dropped

Adversarial review found the one case where the push still left a lying
description: the ensurer's in-flight guard dropped any call arriving while a
probe was already running, so a default changed in that window was read from the
old config and never re-checked. Concurrent calls now collapse into a single
re-run when the in-flight one finishes.

* fix(web-ui): make the project settings form's selects look like selects

The base stylesheet strips a control's UA chrome, so a bare <select> drew as
19px of transparent text with an arrow, sitting beside 35px bordered inputs and
textareas — the "Ambient behavior" dropdown read as a line of body copy. Both
selects on the page were bare; the bot-ledger one had partial chrome and came
out undersized.

A select now carries the same field treatment its input and textarea siblings
already have, from one .field-select rule rather than a fourth per-component
copy, with hover, focus-visible and disabled states.

The form itself read label, three lines of explanation, then control, which is
what orphaned the control from its name. Each field is now label, control, then
the prose explaining it, and the panel's three groups (ambient behavior,
standing orders, automated posters) are separated by a hairline instead of
leaving the reader to infer the boundaries from spacing.

* refactor(web-ui): one dropdown for the whole shell, with the caret inset

The seven selects in the shell each carried their own chrome — four bespoke CSS
rules, three sizes, two of them none at all — and every one of them let the
browser draw the arrow, which a browser pins to the element's edge where it
crowds the border. Right padding moves the text, never the arrow.

fieldSelect() in ui.ts is now the only dropdown. It keeps a native <select>
(a label points at it, the keyboard and the mobile picker work, the option list
can't escape the viewport) and draws the shell's own lucide ChevronDown, inset
12px like every other control's content, with the text stopping 8px short of it.
One rule replaces .list-select select, .deploy-sort select, .ambient-bot-mode
and the two bare ones; a compact modifier covers the toolbar filters.

* fix(web-ui): lift the ambient value mapping out of a nested ternary

* fix(web-ui): model pin survives colons and delisting, and the ambient ordering test bites

Adversarial review findings on this branch:
- a model id containing ':' was truncated by destructured split; parse on the
  first colon only, matching how scope ids are split elsewhere
- a pinned model no longer among the offered options silently displayed as
  'Org default' while the hint claimed a pin; it now renders as an explicit
  selected option marked 'no longer offered'
- the ambient field-ordering test matched '<select' which its sibling test
  forbids, so its select-row assertion was vacuous; it now locates the shared
  dropdown call and requires the control to be present

---------
* fix(web-ui): ten UX papercuts from user feedback

Ten web-UI papercuts from user feedback, all root-caused:

- Buttons stacked their icons above/below the label because the global
  .icon rule is display: block and .btn was not a flex container. .btn is
  now an inline-flex row, fixing every icon+label button at once (Upload,
  Add credential, New cron, Facts view, History, Open, Edit live, ...).
- The New project modal opened top-left: Tailwind's preflight zeroes every
  margin, which strips the UA centering (margin: auto) from modal dialogs.
  Restore it on .project-dialog.
- The theme toggle mounted without includeSystem, so its cycle was
  light <-> dark and "system" was unreachable once clicked. Cycle all
  three, with a tooltip naming the states.
- The collapsed sidebar was an empty 50px strip. It now keeps icon-only
  navigation (tooltips carry the labels) instead of going dead.
- The keychain page put its refresh after the primary action; every other
  list page puts refresh before it. Moved, to match.
- The apps list badged every shared row "Can view", which is noise — you
  are looking at it. Only "Can manage" renders in the list now; the detail
  view still shows both states in its Access field.
- Empty Yours tab said "No apps in Yours."; it now says "You have no apps."
- The add-credential flow spoke in mechanism ("Secure drop", "Create secure
  form"): rewritten in plain language around a private one-time page.
- The sidebar user pill drew a text cursor and selected like prose; it is
  display chrome, so cursor: default and user-select: none.

Nav rows gained title tooltips (needed by the icon rail, harmless open).

* fix(web-ui): align the drop-ready notice with the one-time page copy

The create-drop success notice still said 'Secure
credential form ready.' next to the renamed card.

* fix(web-ui): let the collapsed rail scroll; keep the Yours empty copy tab-scoped

Two review findings:
- The rail's icon nav lived inside the sidebar's overflow:hidden; at short
  viewports (desktop or 44px-minimum mobile landscape) the lower icons
  clipped out of reach. The closed #sidebar-top now shrinks and scrolls.
- 'You have no apps.' claimed account-wide emptiness on a tab-scoped list
  (shared apps may exist — exactly the reported state). Now
  'You haven't deployed any apps yet.', and the existing intent-guard test
  also forbids the account-wide claim in deploy-view.ts.

* style: prettier on the rail test

* fix(web-ui): actually hide nav labels in the collapsed rail; final Yours empty copy

A second review round, both findings valid:
- The rail hid the New chat label but not the .navrow labels, so rows
  centered icon+text as one group and the icons sat off-center/clipped.
  Hide the row labels too; verified in the DOM (all icons centered at
  offset 0 in the 50px rail, labels display:none).
- 'You haven't deployed any apps yet.' still overclaims when your apps
  were transferred elsewhere. Final tab-scoped copy: 'No apps of your
  own yet.'

---------
The server generates a session title at the START of the first turn
(earlyTitleGen), but the sidebar only polled for it after agent_end —
and gave up after ~8s. Long tool-heavy first turns left chats named
'Web chat' for minutes.

Now the title poll starts as soon as the turn begins streaming on an
untitled thread (keyed by threadRef, since the session id isn't known
client-side mid-turn), with a longer backoff (~36s total). The
agent_end poll remains as a fallback.
* web-ui: allow attaching files while a turn is running

The composer disabled the attach button, drag-and-drop, file picking,
and large-paste-to-file whenever the active turn was streaming. There's
no reason to block composing the NEXT message just because one is in
flight: attachments now stay in the composer while streaming. Steering
still sends text immediately (the steer signal is text-only); pending
attachments ride along with the next full message, and the steer
button's tooltip says so.

* prettier

---------
* web-ui: icon-only background chip (cog = jobs, binoculars = watches) with custom tooltip

Replace the Activity-icon + count badge on session rows and split-pane
tabs with per-kind icons: a cog when background jobs are running, and
binoculars when watches are armed. The number is gone; the full spoken
label ("2 background jobs running · 1 watch armed") now lives in a
styled floating tooltip of our own (body-mounted, viewport-clamped),
never the browser's native title bubble. aria-labels keep the full text
for screen readers.

* web-ui: drop unused Activity import; untrack stray node_modules symlink

---------
* web-ui: hover archive quick-action on session rows

Archive is by far the most-used row action, so surface it as its own
hover-only icon button next to the 3-dot menu instead of burying it in
the popover. Both buttons keep the existing hover/menu-open reveal
behavior; the popover still offers Archive/Unarchive too.

* lint: flatten nested ternary in deep-link.ts (no-nested-ternary)

---------
A steer racing its run's end used to vanish: core replayed it only when a
TurnRequest was stored (never true for /v1/runs/:id/signal steers, which were
dropped with a console warning), the client treated the 409 as a hard failure,
and an open tab never attached to a run core started on its own — so the
message disappeared from the web UI even when core did save it.

Core:
- replayOrphanedRunSignals: a request-less orphaned steer is re-enqueued on the
  run's own request instead of dropped.
- signalRun: a steer whose run went terminal mid-send now answers
  { reason: "terminal", replayed: true } so callers know the text rides a
  fresh run.

Web UI:
- signalLiveRun returns a SignalOutcome for 409/404 instead of throwing.
- composer recovers a failed steer: replayed → detach from the stale stream and
  attach to the fresh run; not stored → resend as a normal prompt (text is
  never dropped; worst case it returns to the composer with an error).
- a session-state "working" event attaches the open conversation (previously
  only visibilitychange did), and resumeTrackedRun refreshes the transcript
  before attaching so the triggering message is on screen.
* fix(web-ui): a canvas pane's composer can be typed into again

Every pane wires `focusin` → `focusPane` → `panel.api.setActive()`. Dockview's
`setActivePanel` funnels into `group.model.openPanel(panel)`, and when that panel
is already the group's active panel it takes the `renderPanel(panel, {asActive:
true})` branch — which detaches the pane's content element and re-appends it.
Re-attaching the subtree clears `document.activeElement` to `<body>`, so the
composer was blurred by the very focus event it just received: click it, type,
and the keystrokes went to the document. No pane composer in split mode was
usable; the textarea itself was never disabled, which is why nothing looked wrong.

Activation now happens only when the pane is not already active, and the one
activation that still runs (first click into another pane) restores the focused
element and its caret afterwards, via a `preservingFocus` helper alongside the
existing pane-focus restore.

* fix(web-ui): unnest the deep-link project-segment ternary

Lint was red: no-nested-ternary on the projects
path parse. Same result, one flat expression.
* Give group DMs a pretty project path and unnest the deep-link ternary

* Kick CI
… of dropping it

* fix(web-ui): hold a steer typed before the run slot goes live instead of dropping it

While a turn is submitting (POST /api/turn in flight) and again while its
stream tears down, agent.state.isStreaming is true but the run slot has no
run id. sendSteer's guard treated that window as a no-op: Enter cleared
nothing, sent nothing, and raised no error — the composer just looked dead.
Every Split Canvas pane cycles through this window on every turn, so with
several panes in view the message input appears to randomly stop working.

sendSteer now always accepts the message: it renders as a steered turn
immediately and, when the slot has no live run, steerWhenLive settles it —
steering the run once its id arrives, resending as an ordinary prompt when
the run ended first, or restoring the draft with
an error if the slot never settles.

* style: prettier formatting

* composer: keep newer draft text when a held steer times out

Review finding: the ~10s timeout restore blindly overwrote
composerState.draft, destroying anything typed while the message was
held. Prepend the held text to the newer draft instead.

---------
# Conflicts:
#	src/api/routes/surface.ts
@ReganBell
ReganBell merged commit 5eb3393 into main Aug 4, 2026
20 checks passed
@ReganBell
ReganBell deleted the up-webui branch August 4, 2026 00:08
@16francej 16francej added the enhancement New feature or request label Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants