Skip to content

Workspace page name; @labkit/ui in folders; repeated UI code extracted - #675

Merged
danbarua merged 19 commits into
mainfrom
ui-gardening
Oct 15, 2026
Merged

danbarua merged 19 commits into
mainfrom
ui-gardening

Conversation

@danbarua

Copy link
Copy Markdown
Owner

Problem

  • The page at /app/<API path> was still called the Trace Console in its component, folder, CSS root class, test file, log prefixes and saved-layout keys, while the app on screen is LabKit.
  • @labkit/ui kept its 30 files side by side, and format.ts mixed the line diff, tool status labels and the cost format.
  • The same code was written out several times in @labkit/ui and in the workspace page: icon-only buttons, byte formats, type guards, reduced-motion checks, class-list joining, collection paging, paged-list tails, the two resizable borders, load effects, record chips and the error-message expression.

Change

One commit per change:

Commit What it does
Rename src/ui/trace/ is src/ui/workspace/; TraceConsole is WorkspacePage; .trace is .workspace-page; logs start workspace page:; saved keys are labkit.workspace.*.
Folders @labkit/ui files sit in base/, content/, conversation/, tool/, composer/, requests/ and overlay/. Only paths and relative imports change; index.ts and the two stylesheets are the public surface, unchanged.
format.ts split Diff code is content/diff-rows.ts; status labels are in tool/tool.tsx; number formats are base/format.ts.
One byte format formatBytes replaces formatBytes and compactBytes; compactCount and shortDuration move beside it.
One isRecord, one httpUrl isRecord was defined four times (including isPlain in the app); the HTTP(S) address check twice.
ContentView to content/ A message no longer imports the tool card's module to draw its content. useMediaSource loads image and audio blocks for three views.
IconButton Draws the icon-only buttons in @labkit/ui and the workspace page's rail, chat and filter buttons. unavailable disables a button and makes the reason its hover text.
Media helpers REDUCED_MOTION, prefersReducedMotion() and useMediaQuery() replace six copies of the reduced-motion query and the page's own hook.
classNames Joins conditional class lists in ten places; two of them wrote doubled or trailing spaces.
Frame reuse Frame takes onHome and sets the icon defaults; the workspace page uses it instead of building its own root and top bar.
Sidebar sections File, Source and Record Explorer come from one SECTIONS table and useSection; FilterButton and FilterBox take the filter object.
useCollectionPages Pages a HAL collection for five lists; hrefsOf in git-views.tsx gives way to hal.ts's hrefOf and linksOf.
PagesTail The failure note, loading line, empty note and next-page button under six paged lists.
Separator The ARIA window splitter's drag and key handling, shared by the sidebar's borders and the chat's border.
messageOf One copy of err instanceof Error ? err.message : String(err) for 13 sites in packages/app-web/src.
useLoaded The load, cancel and failure effect of five components; useAgentSessions and sessionsNote shared by the sessions page and /sessions.
Mention A record's chip in prose, in an event's ends and in the commits list, in its three forms.
Unused import useRouterState in the workspace page.
Replay recording The session replay passes one Recording object, so a new render of the page does not restart playback's timer.

What a person can see change

  • The saved layout (sidebar, chat, section folds and sizes, chat width) resets to the defaults once, because its keys are renamed.
  • Byte sizes: an attached file of 1536 bytes reads "1.5 KB" (was "2 KB"); a linked resource of 2048 bytes reads "2 KB" (was "2.0 KB").
  • The toast's Dismiss and the modal's Close buttons show their label as hover text.
  • The sessions page says "Asking the agent for its sessions…" while it waits (it showed nothing).
  • /sessions waits one tick before it asks, and a search that matches no session says "Nothing matches" (it said "The agent has no sessions in ").

Not done

From a survey of the app's .tsx files, these were left, each for the reason given:

  • Hand-made .panel cards beside ContentCard (Properties, related resources, events, commits): moving them to ContentCard changes how those cards look and fold.
  • Record Explorer's Items filter to filterItems: it would change how a term matches (every word rather than one substring) and the order of rows in a paged list.
  • A ToggleGroup for the Rendered/Source and structural/temporal toggles, a ResourceHead for the badge, id and type triple, and a FetchError block: two to four sites each.
  • A search-param setter for the workspace page's three navigate({ search }) calls, all in one function.
  • GraphView's repeated projection, neighbour and edge maths: one file, canvas code, best changed with the graph's own tests.
  • The two follow-the-end scroll hooks in @labkit/ui (thought body and transcript): they differ in slack and in the transcript's resize and gutter handling.
  • Conversation building its own root beside Surface: Surface draws a div, Conversation a section.
  • The development pages (gallery tiles, message pages, withSetting).
  • ui.css stays one stylesheet: splitting it by folder would reorder rules whose order decides which wins.

Controls

  • Typecheck: bun run web:check-types, and tsc -p packages/ui for the package and its tests.
  • bun test for packages/ui and packages/app-web after each commit.
  • bun run check and the e2e suite on the final tree.

Verification

  • bun run check on the final tree: all 28 passed; bun test 1105 pass, 0 fail.

  • E2E_PORT_API=9019 E2E_PORT_DEV=8970 E2E_PORT_BUILT=8971 bun run e2e on the final tree: 175 passed, 0 failed, 11 skipped: in the built project, the gallery, the sessions page, the agent page and the chat exist only in development. Each of those 11 tests passed in the dev project.

  • git grep -i "trace console" finds nothing.

Linked issues

None.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GVjyjkaADPAqLvtnf3xUNs

danbarua and others added 19 commits October 15, 2026 09:11
…d docs

The page at /app/<API path> was still called the Trace Console in its
component, folder, CSS root class, test file, log prefixes and storage
keys, while the app on screen is LabKit. The names now follow the
workspace:

- src/ui/trace/ is src/ui/workspace/; TraceConsole.tsx is
  WorkspacePage.tsx and exports WorkspacePage.
- trace.css is workspace.css; its root class .trace is .workspace-page.
- Log messages start "workspace page:".
- The saved layout keys labkit.trace.* are labkit.workspace.*. A browser's
  saved layout resets to the defaults once.
- tests/trace-console.test.tsx is tests/workspace-page.test.tsx.

The e2e assertion that the top bar does not say "Trace Console" is
removed: no code produces that text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVjyjkaADPAqLvtnf3xUNs
The package had 30 files side by side. They now sit in folders:

- base/: Surface, the theme button and the loader.
- content/: content of unknown length (card, code, diff, markdown,
  plots, values) and the contexts it draws with (links, records).
- conversation/: the transcript, its blocks, the activity indicator,
  grouping of tool runs, and items leaving.
- tool/: tool call cards and the per-tool views.
- composer/: the composer, attachments and the session controls.
- requests/: the agent's requests to the person (permission and
  elicitation).
- overlay/: unchanged.

Only paths and relative imports change. The package's public surface,
index.ts and the two stylesheets, is the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVjyjkaADPAqLvtnf3xUNs
format.ts held three unrelated things: the line diff and patch parsing,
the tool call status labels, and the cost and context-window formats.

- The diff code is content/diff-rows.ts, beside the diff view that
  draws it.
- The status labels are in tool/tool.tsx, their only user.
- formatCost and fillPercent are base/format.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVjyjkaADPAqLvtnf3xUNs
…rmats

Two functions wrote a byte size, with different results for the same
size: formatBytes in attachments.tsx (rounded to whole kilobytes) and
compactBytes in tool.tsx (one decimal place below 10, and "2.0 KB" for
2048 bytes). base/format.ts now has one formatBytes: one decimal place
below 10 without a trailing ".0", rounded from 10 up. A visible change:

- An attached file of 1536 bytes reads "1.5 KB", not "2 KB".
- A linked resource of 2048 bytes reads "2 KB", not "2.0 KB".

compactCount and shortDuration move from step-stats.tsx to
base/format.ts, and compactCount uses the same rounding helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVjyjkaADPAqLvtnf3xUNs
- isRecord was written out in value.tsx, tool.tsx and tool-views.tsx,
  and as isPlain in the app's values.tsx. value.tsx exports it, and the
  package's index exports it for the app.
- links.tsx (fetchable) and elicitation.tsx (webAddress) each parsed an
  address and accepted only http and https. Both use httpUrl in
  links.tsx. A link the host resolves is still read against the page's
  address; an elicitation's URL must still be absolute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVjyjkaADPAqLvtnf3xUNs
ContentView draws any ACP content block, in a message as well as in a
tool's result, but lived in tool.tsx, so a message imported the tool
card's module to draw its text and images. It and its image, audio and
resource views are content/content-block.tsx.

An image or audio block loads from its bytes, or else from its link
once the host resolves it. ImageView, AudioView and the sent-image view
in attachments.tsx each wrote this out; useMediaSource in links.tsx is
the one copy, and AudioView no longer casts its block to read a uri.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVjyjkaADPAqLvtnf3xUNs
…e page

Each icon-only button wrote out the same five things: type="button", its
class, an aria-label, the same text as its title, and an icon hidden from
assistive technology. IconButton in base/icon-button.tsx takes a label
and an icon component, plus:

- on: adds the `on` class, for the rail and filter toggles.
- unavailable: disables the button and makes the reason its hover text,
  as the rail's section buttons and the chat button did.
- title and size, where the hover text or the icon's size differ.

It draws the message toolbar's buttons, the theme button, the composer's
attach and mention buttons, the remove button on an attachment, the
session settings button, the toast's dismiss button, the modal's close
button, the rail's buttons, the chat button and the filter button. The
composer's send and stop buttons, which have a style of their own, and
its `/` button, which is a character rather than an icon, are unchanged.

A visible change: the toast's Dismiss and the modal's Close buttons now
show their label as hover text, as the other icon buttons do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVjyjkaADPAqLvtnf3xUNs
…abkit/ui

The "(prefers-reduced-motion: reduce)" query was written out six times:
in the loader, tool.tsx, leaving.tsx, active-box.tsx, the workspace
page's section motion and GraphView. base/media.ts in @labkit/ui has:

- REDUCED_MOTION, the query.
- prefersReducedMotion(), which reads it once (true outside a browser,
  as leaving.tsx's copy did).
- useMediaQuery(query), moved from the workspace page, which keeps a
  component current as the query starts or stops matching.

GraphView reads reduced motion through useMediaQuery instead of adding
and removing its own change listener. The loader still reads one
MediaQueryList on each frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVjyjkaADPAqLvtnf3xUNs
Ten class attributes were built from a list of conditional parts, in
four ways: join with filter, join without filter, join and trim, and
a template string. The two without a filter wrote doubled or
trailing spaces, such as "sidebar  collapsed" and "active-box fill
placed ". classNames in base/class-names.ts leaves out false, null,
undefined and empty parts and joins the rest with one space. It builds
the classes of the content card, Surface, the modal, the frame, the
sidebar, an explorer section, a resource row, the chat sidebar, a
commit row and the active box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVjyjkaADPAqLvtnf3xUNs
…again

Frame did not let a page set what the LabKit name does, so the workspace
page built its own root (palette class, theme attribute, icon defaults)
and top bar beside Frame. Frame now takes onHome and passes it to the
top bar, and it sets the icon defaults (ICONS) for every page. The
workspace page renders <Frame className="workspace-page" onHome={home}>,
and TopBar is no longer exported.

The pages that use Frame draw no icons outside a conversation, which
sets the same defaults itself, so no icon changes size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVjyjkaADPAqLvtnf3xUNs
… one object

File, Source and Record Explorer each had their own saved open flag,
saved share of the height, section ref, share object for the borders,
rail button call and Explorer props, written out three times.

- SECTIONS names each section's name, icon, whether it starts open and
  its first share of the height.
- useSection(id, unavailable) keeps a section's open flag and share
  under labkit.workspace.<id> and labkit.workspace.<id>.grow, as before.
- The rail's buttons are drawn from the list of sections; Explorer and
  Splitter take a section.
- FilterButton and FilterBox take the useFilter object, and FilterBox
  draws nothing while its filter is hidden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVjyjkaADPAqLvtnf3xUNs
Five lists paged a collection with the same reader, written out three
ways: `read` in explorers.tsx, `readCollection` in the workspace page,
and an inline function twice in git-views.tsx. useCollectionPages(href)
in hal-store.ts requests href, keeping any query such as ?limit=200,
and reads each page with collectionOf. It pages a directory and the
history's timeline and branches in the explorers, Record Explorer's
two panes, and a commit's changed files and diff.

git-views.tsx's hrefsOf duplicated hal.ts's linksOf and hrefOf; its
callers use those.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVjyjkaADPAqLvtnf3xUNs
Six lists ended with the same choice, written out each time: a "Fetch
failed" note, a loading line, an empty note, and a button for the next
page. PagesTail in pages-tail.tsx makes that choice once; each list
gives its note class, loading line, empty note and button text:

- File Explorer's directories and Source Explorer's timeline (the
  explorers' own Tail component, now removed).
- A commit's changed files and its diff.
- Record Explorer's two panes. Pane's prop `empty`, which was the text
  shown while the first page loads, is renamed `firstLoad`.
- The events of the Debug tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVjyjkaADPAqLvtnf3xUNs
The border between two sidebar sections (Splitter) and the border at
the chat's left edge (ChatBorder) each wrote out the ARIA window
splitter: the separator role and value, pointer capture on press, the
drag's starting point, the move, release and cancel handlers, the
double-click reset and the key handler. Separator in separator.tsx
holds that once. Each border gives its orientation, value range, what a
drag starts from, how it moves by a number of pixels, and its keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVjyjkaADPAqLvtnf3xUNs
`err instanceof Error ? err.message : String(err)` was written out 13
times in packages/app-web/src: in the server, the dev-server plugins and
the browser code. messageOf in src/message-of.ts is that expression.
The dev scripts and tests keep their own copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVjyjkaADPAqLvtnf3xUNs
Five components each wrote an effect that set a loading state, started
a request, ignored its result once the effect was cleaned up, and
turned a rejection into a failure message: the transcript mockup, the
session replay, the sessions page, the /sessions palette and the chat's
folder lookup. useLoaded(load, key, {enabled, deferred}) in loaded.ts
does it once and returns loading, failed with a message, or loaded with
the value. `deferred` keeps the one-tick wait that stops React's
development double-invoke from opening two agent connections.

The sessions page and the /sessions palette also shared their listing
and sorting code and their notes; agent/sessions.ts holds
useAgentSessions and sessionsNote. Changes a person can see:

- The sessions page says "Asking the agent for its sessions…" while it
  waits, as the palette did, instead of showing nothing.
- The palette waits one tick before asking, as the sessions page did.
- When a search in the palette matches no session, it says "Nothing
  matches" instead of "The agent has no sessions in <folder>".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVjyjkaADPAqLvtnf3xUNs
The chip for a record (class `mention`, in its type's colour, its type
as hover text, opening the record when pressed) was written out in the
prose of a resource, in each end of an event, and for each commit the
acts ran against, with the dashed chip for an unknown handle written
twice more. Mention in values.tsx draws its three forms: a button that
opens the record, the open record marked and not pressable, and a
handle with no known record.

The prose switch names its "unknown" segments as a case of their own
and checks that every kind of segment is handled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVjyjkaADPAqLvtnf3xUNs
…t use

biome reported the import as unused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVjyjkaADPAqLvtnf3xUNs
Since useLoaded, SessionReplay built `{ events }` while rendering, a new
object each time. The replay's playback effect depends on the
recording, so any new render of the page would restart its timer for
the next event. The load now resolves to the Recording itself, which
stays the same object until the session changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVjyjkaADPAqLvtnf3xUNs
@github-actions github-actions Bot added the area:web The web app and shared UI: app-web, ui, design, records, view-model, acp-client label Oct 15, 2026
@danbarua
danbarua merged commit 15a1350 into main Oct 15, 2026
10 checks passed
@danbarua
danbarua deleted the ui-gardening branch October 15, 2026 12:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:web The web app and shared UI: app-web, ui, design, records, view-model, acp-client

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant