web-ui: a UI pass over the chat, inbox, settings, and sidebar surfaces - #976
Open
evebouf wants to merge 27 commits into
Open
web-ui: a UI pass over the chat, inbox, settings, and sidebar surfaces#976evebouf wants to merge 27 commits into
evebouf wants to merge 27 commits into
Conversation
…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>
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>
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>
…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>
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>
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>
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>
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>
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Twenty-four small changes, all one- or two-file UI work. One line each.
Chat page
Inbox item page
detailselement.Settings page
Sidebar and filters
aria-labeland nothing visible.Everywhere
--scrimtoken, so a modal dims the page instead of washing it out with near-white.--accent-foregroundinstead 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