Skip to content

web-ui: a UI pass over the chat, inbox, settings, and sidebar surfaces - #976

Open
evebouf wants to merge 27 commits into
mainfrom
evebouf/drop-transcript-fades
Open

web-ui: a UI pass over the chat, inbox, settings, and sidebar surfaces#976
evebouf wants to merge 27 commits into
mainfrom
evebouf/drop-transcript-fades

Conversation

@evebouf

@evebouf evebouf commented Sep 8, 2026

Copy link
Copy Markdown

Twenty-four small changes, all one- or two-file UI work. One line each.

Chat page

  • Transcript edges clip instead of fading behind a gradient and a blur.
  • The pinned prompt paints the background and covers what scrolls under it, instead of fading it.
  • Collapsed plain-text code fences clip their overflow instead of fading it.
  • A long pinned prompt gets Show more instead of a second scrollbar inside the transcript.
  • Message hover actions are hittable: the copy button goes from an 18px square to 24px, gap 6px to 8px.

Inbox item page

  • The page scrolls instead of squeezing the thread into the viewport; the draft may fall below the fold.
  • The thread caps at 480px and offers Show the whole thread when there's more.
  • An email draft folds To, Cc, and Subject away, collapsed by default, behind a line naming the recipient and subject.
  • The folded fields get 8px between them, which the first cut lost by putting the gap on the details element.
  • The header anchors to the top of its row instead of floating in the middle of one the assistant stretched.
  • The assistant ends at the pane's content box, so its own height stops scrolling the page.
  • The assistant tracks the window between a 320px floor and a 1100px ceiling.
  • The assistant's size observer is replaced on remount rather than stacked.
  • The empty assistant offers three starting prompts: shorter, friendlier, drop the salutations.
  • The inbox shows the same working wave as the rest of the app.
  • The sync control uses the app's tooltip and the shared icon size.
  • The inbox list drops its subtitle.

Settings page

  • The theme picker sits right like every other row's control, instead of wrapping to its own line.
  • Import theme file is a small link rather than a bordered button competing with the theme choice.

Sidebar and filters

  • Archive and the per-chat overflow button get tooltips; they had an aria-label and nothing visible.
  • Count badges use tabular figures so a badge stops reflowing its row as digits change.
  • A nav entry always lands on its own index.
  • The context and surface filters become one control.
  • Filter menus open below their button rather than over the header above, and drop the header that repeated the button.
  • List filter captions stop bolding the dropdown they label, at five call sites.

Everywhere

  • Dark mode gets a --scrim token, so a modal dims the page instead of washing it out with near-white.
  • Dark mode defines --accent-foreground instead of letting three rules fall back to #fff.

web-ui 973/973, typecheck, oxlint, knip, prettier clean. Exercised against a live dev instance at 620px, 880px, and full height, in both themes.

🤖 Generated with Claude Code

…them

The chat scroller painted a gradient plus a mask at both edges, the pinned
user row faded the transcript beneath it, and a collapsed plain-text fence
faded its own overflow. Three variations on the same idea: dissolve content
into the background so a hard edge never shows.

They cost more than they bought. The edge pseudo-elements sat above the
transcript with a backdrop blur, so text passing under them went soft before
it left the viewport, and the sticky row needed a scroll listener toggling a
class on the last user row to know when to switch its fade on. Replace all of
it with the plain thing: the pinned row paints var(--background) and occludes
what it covers, and the scroller clips at its own bounds.

--chat-edge-fade becomes --chat-prompt-gap, which is what the surviving use
was always for: the space under the pinned prompt.

sticky-prompt-fade.test.ts asserted the fade existed, so it goes; a new
occlusion test asserts the replacement and that no transcript surface
dissolves content into the background.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@evebouf evebouf changed the title web-ui: the transcript clips at its edges instead of dissolving into them web-ui: tidy up the chat page's edges Sep 8, 2026
@evebouf evebouf self-assigned this Sep 8, 2026
test and others added 2 commits September 7, 2026 23:57
The pinned user row is capped at 40% of the scroller. Past that the body
scrolled inside the bubble: a second scrollbar inside the transcript's own,
with no sign of how much it hid. It now clips and offers Show more, and the
toggle lifts the cap.

markClampedPrompts measures the pinned bubble after each render and marks it
data-clamped, so the toggle only appears when the text actually overflows.
Both transcript surfaces (live and read-only) re-measure. The expanded index
lives on chatState and resets when the session changes, so a prompt never
comes back expanded under a different conversation.

One subtlety the first cut got wrong: settled rows render through
settledRowCache, whose key is a hand-written list of the row's mutable render
inputs. expandedPrompt was a new one and was missing, so clicking Show more
mutated the state, redrew, and got the stale row back from cache — the bubble
only changed once some unrelated field (a new message, a work status) happened
to invalidate it. The key now covers it, and the test that claims to cover
every mutable input has been given the four fields it had drifted past.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The per-message action lane reserved 18px (16px compact), so the copy button
was an 18px square with a 6px gap to its neighbour — under every pointer-target
minimum, and fussy to hit on a trackpad. The lane goes to 24px (20px compact)
and the gap to 8px. Both are the same variable the row already reserves as its
rhythm, so the buttons and the space between message blocks grow together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@evebouf
evebouf requested a review from 16francej September 8, 2026 06:57
Three rules read var(--accent-foreground, #fff). The light palette defines the
token; the dark one never did, so all three took the fallback and painted pure
white on an accent that is already light in dark mode. Define it alongside
--accent so the fallback stops being load-bearing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@evebouf
evebouf requested a review from ReganBell September 8, 2026 06:57
test and others added 2 commits September 7, 2026 23:57
…shouting

Two problems in the Theme row, both from the row being built for a short note.

.settings-row is space-between with wrap, and .settings-row-copy had no flex
rule, so the Theme note (the only long one on the page) took its full
max-content width and pushed the controls onto a second line. A lone item on a
wrapped line sits at the line's start, so the picker ended up hard left while
every other row's control sat right. The copy column now shrinks, and the
controls keep margin-left: auto for the widths where they still wrap.

The import affordance was a bordered button with an icon, the same weight as
Open admin and Sign out, which put it on equal footing with the light/dark/
system choice next to it. It becomes a small underlined link under the picker.
Still a real button, so the keyboard and the remove control's aria-label are
unchanged; only its clothes are.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.list-select carried font-size: 11px and font-weight: 600 for the small caption
it wraps (Scope, Source, Show). fieldSelect renders its select with
font: inherit, and the shorthand takes the inherited weight with it, so every
dropdown built through it came out semibold. The compact rule re-set font-size,
which is why only the weight looked wrong.

Move the caption's type onto the caption. The :not(.field-select) matters:
fieldSelect's own wrapper is a span too, and a bare > span rule feeds the
weight straight back into the control.

All five call sites had it — All surfaces on the chats list, Active only on
projects, All scopes and All sources on skills, and the files filters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@evebouf evebouf changed the title web-ui: tidy up the chat page's edges web-ui: tidy up the chat page's edges, and the controls around it Sep 8, 2026
@evebouf evebouf changed the title web-ui: tidy up the chat page's edges, and the controls around it web-ui: small UI fixes across chat, settings, and the list filters Sep 8, 2026
test and others added 4 commits September 8, 2026 00:09
Every modal scrim mixed its dim from var(--foreground). In light mode that is
near-black and reads correctly. In dark mode --foreground is near-white, so
opening the palette, the browse sheet, a project dialog or the mobile sidebar
painted white over the page at 24-42% — the page behind went pale grey and the
app looked switched to a light theme around the modal.

A scrim is not a function of the text colour. Add a --scrim token each theme
points at its own dark end: near-black in light, and in dark a navy a shade
below --background, so the page recedes along the palette's own hue instead of
washing toward white. Every scrim mixes from it now, including
.kc-dialog-scrim, which had hardcoded #000 to sidestep the same problem.

Imported terminal palettes fill every colour slot the dark theme fills, so
themeTokens derives --scrim too, mixing the palette's own background toward
black. The existing bleed-through test caught that omission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Archive and the per-chat overflow button carried an aria-label and nothing a
sighted pointer user could read. Both get the tooltip the other icon-only
controls in the rail already use, and the overflow button's label is hoisted
to a constant beside the two that were already there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The nav badge and the inbox and pane-kind counts set line-height: 15px against
an 11px font, which left the digits sitting high in the pill, and proportional
figures meant 11 and 47 rendered at different widths — a badge that reflowed
its row every time a count ticked. Tabular figures, a 14px line box, and a
pixel of bottom padding settle them.

The inbox chip also needs a little more right padding when it carries a count,
and rather more when density hides the count entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replying from the inbox almost never means changing who it goes to or what
it is called: the agent fills both from the thread it is answering. Yet the
three fields sat above the body on every email item, so the first thing a
reader met was routing they were not going to touch, and the draft itself
started a third of the way down.

Fold them into a details, shut by default, behind a line that still names
the recipient and the subject. Nothing is hidden, only unstacked, and the
one case that needs an edit is a click away. A details element keeps its own
open state, so a redraw mid-edit does not close it, and no state has to be
tracked to do that.

The default subject was computed inline in the input's value binding, where
the summary could not reach it. It moves to draftSubject() and both callers
read the same string.

Slack drafts are unchanged; they never had these fields.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@evebouf evebouf changed the title web-ui: small UI fixes across chat, settings, and the list filters web-ui: small UI fixes across chat, inbox, settings, and the sidebar Sep 8, 2026

@ReganBell ReganBell left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

send!

test and others added 3 commits September 8, 2026 11:09
The gap belonged to the details element itself, which does not work: a
details lays its content out inside an anonymous box, so summary and that
box are the flex items and the three fields inside it are spaced by nothing.
They rendered flush, 31px tall and 31px apart.

Give the fields a real wrapper and put the flex column on that. The details
goes back to being a plain block whose only job is opening and shutting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The item page was a fixed frame: a grid whose second row took whatever the
viewport had left, with the thread column scrolling inside it. Opening the
draft's To and Subject took that space from the thread, which shrank until a
three-message conversation showed one and a half messages behind its own
scrollbar. The page never scrolled; everything else did.

Let the page scroll instead. The thread renders at its natural height and the
draft falls below the fold when it must, which is the ordinary way to read a
long thread and write under it.

A thread still needs a ceiling, or forty messages bury the reply. It caps at
480px and offers to show the rest. The toggle only appears when there is more
than the cap shows, measured after the render that drew it, the same way the
pinned prompt decides whether it needs Show more.

The assistant keeps its full height by sticking to the top of the scrolling
page rather than by being a row in a frame. Its height is the viewport below
where it starts, set from the pane's own padding so it does not assume the
pane fills the window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…etched

The assistant spans both grid rows, and once the page stopped being a fixed
frame the rows sized themselves to fit it, so the header row grew to most of
the assistant's height. .pane-head centres its content, which left the title
and subject adrift in the middle of a 209px row: 71px of nothing between the
subject line and the rule under it.

Anchor the head to the top of its row and let the second row take the slack.
The gap under the subject is the 12px padding it was always meant to be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@evebouf evebouf changed the title web-ui: small UI fixes across chat, inbox, settings, and the sidebar web-ui: small UI fixes across chat, the inbox item page, settings, and the sidebar Sep 8, 2026
test and others added 2 commits September 8, 2026 12:46
While the agent answers an inbox ask, the panel printed a bare "Thinking…".
Everywhere else a working agent is the blue swell — the sidebar row mark, the
chat transcript's typing row — so the inbox now leads that line with the same
workingWave() the sidebar uses, in the same --working-dot blue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eiling

sizeAside ran once per render and never again, so the assistant kept whatever
height the viewport had when the page drew. Resizing the window left the
composer stranded: a gap below it on a taller window, and pushed off the bottom
of a shorter one.

A ResizeObserver on the page host recomputes it, which also covers the sidebar
collapsing and split-pane drags rather than only window resizes. The height now
clamps at both ends instead of only flooring, and re-writing the same value is
skipped so the observer cannot feed itself through a scrollbar.

Below the floor the window stops winning: the assistant keeps its minimum and
overflows into the page scroll rather than crushing its own composer. The
pre-JS CSS fallback carries the same bounds as the measured value.

The chat composer grows with what you type against the same measured height,
and no longer disables itself mid-turn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@evebouf evebouf changed the title web-ui: small UI fixes across chat, the inbox item page, settings, and the sidebar web-ui: a pass over the chat, sidebar, inbox and theme surfaces Sep 8, 2026
test and others added 4 commits September 8, 2026 12:52
sizeAside subtracted the pane's top padding and then a hardcoded 8px, which is
not the padding underneath it. The assistant ran 32px past the pane's content
box, so on a window with room to spare its own height was enough to give the
page a scrollbar.

Measure the bottom padding the same way as the top. The two ends now come from
the same place, and the assistant cannot scroll the page by itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
observeAsideSize built a ResizeObserver and dropped the reference. The page
host is rebuilt whenever the view is left and returned to, so each visit
started another observer over another host, and none of them could be stopped.
Every other observer in the surface — the composer's autosize, the share
sheet's, the split's density sync — is held and disconnected.

Hold this one the same way and disconnect the previous before observing the
new host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Everything waiting on a reply from you, drafted and ready to send" explained a
list that explains itself, on the one page a reader reaches by pressing Inbox.
Every other list page — Files, Projects — is a bare title, so the wrapper the
subtitle needed goes with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…icon size

The sync button and the status text beside it carried native title attributes,
so the one control on the page that explains itself did it in the browser's
grey system bubble rather than the tooltip every other icon action uses. Both
go through tip(), along with the set-up-sync button in the other state of the
same line.

The button was also a 34px square in a 12.5px line, which is what set that
row's height. Icon actions sitting in dense rows are 28px elsewhere, so far
only under .cron-action-btn — a page-specific name for a size five other
call sites would want. It becomes .icon-btn.compact, crons uses it, and the
inbox sync button is the second caller. Its glyph goes 13 to 14 to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test and others added 5 commits September 8, 2026 13:02
The chats page carried two filters that did the same job and agreed on
nothing. Filter by context was the popover menu — a borderless pill that
filled with --secondary on hover. Filter by surface was a native select in
its compact size — a bordered 30px box. Side by side in the same header they
read as two unrelated widgets.

Both are now menuSelect(), a new helper in ui.ts over the .form-menu-control
machinery that already backed the context filter, so the popover, the
placement logic and the checked-option markup come from one place instead of
being spelled out at the call site. scopeFilterControl loses its hand-rolled
copy of that markup, and the surface filter stops being a native select.

Their shared box is .field-menu, which restates the resting state of
.field-select > select — same border, radius, background, foreground, 34px,
13px — so the pair matches, and matches the native selects still in use on
skills, projects and files. Hover darkens the border rather than filling the
button, which is what the native control does. .scope-option-label was only
ever the icon-and-label row inside these options; it becomes
.menu-select-option now that two filters share it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pressing Inbox while an inbox item was open did nothing. switchView returns
early when the view is already current, and the refresh path it delegates to
only redrew — so the item stayed open and the URL kept its id. The same held
for webhooks, crons, loops and skills: once a detail page was open, its nav
entry was a dead control, and the only way back to the list was the in-page
back link.

The detail resets switchView already ran when arriving from elsewhere move into
one resetActiveDetail, and both paths call it. Inbox gains the reset it never
had, so it now behaves like its four siblings on both paths.

Deep links still win. The router sets the pending item before calling
switchView, and the reset clears only the current selection, so the pending id
is applied by the render that follows.

resetActiveInboxItem is the piece closeInboxItem already did inline; the
in-page back link goes through it too, so both routes persist the open draft
before dropping it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ve it

.menu-popover is anchored bottom: calc(100% + 8px) because its first home was
the composer toolbar, which sits at the foot of the window and has to open
upward. The context filter overrode that with its own top/bottom pair. When
both filters moved onto the shared control, the surface filter inherited the
composer's default instead and opened over the page header.

The mechanism for this already exists: data-drop="down" on the control, which
also keeps the .drop-up flip so a filter near the bottom edge still fits.
menuSelect sets it, and .scope-filter drops its copy of the same two
declarations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An empty assistant asked "What should I change?" and left you to think of
the answer. The three answers people actually give are shorter, friendlier,
drop the salutation, so offer them as chips under the question. Clicking one
sends it, the same path the composer takes.

They live in the empty state only. Once a conversation exists the question
has been answered and the chips would be clutter above the log.

Hardcoded for now. The list wants to come from the draft eventually, since
"remove the salutations" is nonsense on a Slack reply that has none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both menuSelect callers passed a title, so opening the context filter put
"Filter by context" above two options while the button beside it already read
"Filter by: All contexts". The surface filter did the same. A menu-title earns
its place where a popover holds several groups — the composer's model, harness
and effort lists, the webhook verification block — but these hold one list, and
the header only restated the control it hung from.

No caller is left passing it, so the prop goes with them. ariaLabel stays: the
button still names itself for a screen reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@evebouf evebouf changed the title web-ui: a pass over the chat, sidebar, inbox and theme surfaces web-ui: a UI pass over the chat, inbox, settings, and sidebar surfaces Sep 8, 2026
test and others added 3 commits September 8, 2026 16:45
Three files conflicted, and main had moved further than the hunks suggested.

chat.ts: main replaced the hand-rolled scroll-follow with transcriptViewport.
Took main's, and kept this branch's markClampedPrompts call beside its sync so
the pinned prompt still learns whether it needs Show more.

shell.css and inbox.ts: main's side throughout. It had already removed the
transcript edge fades this branch set out to remove, arriving there by its own
route, and it then solved the pinned prompt differently — a shadow on a stuck
bubble driven by transcript-viewport.ts, rather than this branch's opaque
paint-over. It also moved Send and Dismiss out from under the draft and into
the assistant composer as suggest chips, retiring sendLabel and
inbox-draft-actions. That is newer work by someone else and it wins.

What survives from this branch is everything main did not touch: the folded
To/Cc/Subject, the 480px thread cap with Show the whole thread, the page that
scrolls instead of squeezing the thread, the assistant sized to the pane's
content box, the empty-state prompt chips, and the settings, sidebar and
filter fixes. The two chip rows turn out to read fine together: prompts under
the question, actions on the composer.

sticky-prompt-occlusion.test.ts loses the case that asserted the paint-over.
Its other two still hold — no edge fades, nothing dissolving into the
background — and both sides agree on them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The merge that brought main in resolved every conflicting hunk in inbox.ts
in main's favour, and this line was one of them. Main's side said plain
"Thinking…"; this branch had already replaced that with the wave the rest of
the app uses. Taking theirs wholesale reverted it without anyone deciding to.

Nothing else in that hunk was contested — only the wave.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Main landed a fix for the inbox conversation dropping its most recent
message: contextTpl now appends the waiting message as a row of its own and
threads an image index through, and the standalone image block under the
thread goes away. Both conflicts are in code this branch also rewrote.

inbox.ts is a union: main's row construction and imageIndex, this branch's
folded To/Cc/Subject and the rest of the item page around it.

shell.css gives up this branch's 480px thread cap and its Show the whole
thread toggle. The cap clipped from the top, and after main's change the
newest message is the last row, so a long thread would hide exactly the
message main just fixed the loss of, behind a toggle. The page already
scrolls rather than squeezing the thread, so nothing is buried without it;
a long thread makes a long page and the draft sits below the fold.

markClampedThread, threadMoreTpl and the two Sets behind them go with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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