Skip to content

Side panel: visible close buttons + drag-to-resize, and artifact surface routing - #271

Merged
vjvarada merged 2 commits into
mainfrom
claude/web-artifacts-builder-review-6vi7lq
Jul 28, 2026
Merged

Side panel: visible close buttons + drag-to-resize, and artifact surface routing#271
vjvarada merged 2 commits into
mainfrom
claude/web-artifacts-builder-review-6vi7lq

Conversation

@vjvarada

Copy link
Copy Markdown
Contributor

Two reported side-panel bugs, plus the routing rule that decides when an agent should render an artifact and where it should go.

1. Side panel fixes (00f2fe5)

Close button was invisible. Each tab's × was opacity-0 until you hovered that exact tab — unreachable on touch, and undiscoverable on a pointer device unless you already knew it was there. Now always rendered: dimmed at rest, full strength on hover/focus, with an aria-label and a visible focus ring.

Panel had a fixed width. It was hard-coded w-[30rem], so a wide table or dashboard had nowhere to go. The right edge is now a drag handle:

  • drag to resize, double-click to reset
  • arrow keys (Shift = coarse step) for keyboard users
  • role="separator" with aria-valuenow/min/max
  • clamped 320–1100px, persisted per browser
  • a 7px hit area straddling the edge, since a 1px border is impossible to grab

Content reflows on its own — the panel was already flex throughout.

Why pointer capture, not the existing window-listener pattern

TimeGrid does drags with window listeners, and I deliberately didn't copy it. This panel renders artifacts inside an iframe, and a window listener stops receiving pointermove the moment the cursor crosses it — the drag dies mid-gesture, which is exactly the path a user takes when narrowing the panel.

Measured both approaches on the same 200px leftward drag across an iframe:

Approach Samples received Panel width
Pointer capture (shipped) 9 480px → 280px
Window listeners 1 480px → 480px

The conventional pattern would have been visibly broken here.

A latent bug this surfaced

openDoc built its next state without spreading the previous one:

_emit({ open: true, docs, activePath: doc.path });   // width dropped

Adding width to the store would have silently reset a user's resized panel every time a document opened. Fixed, and pinned by a test. TypeScript caught it, but only because the field was required.

Also: width is not seeded from localStorage at module load — that module is evaluated during SSR too and would desync hydration. hydratePanelWidth() applies it after mount, invisible in practice since the panel starts closed.

2. Artifact surface routing (161eb9f)

The kit shipped, but nothing told an agent when to reach for it. The guidance said "reach for UI eagerly" with no threshold and said nothing about inline vs panel — so the default stayed a markdown table in chat no matter how much data there was.

Added a rule keyed on volume, with numbers rather than judgement calls, because vague advice doesn't change model behaviour:

The answer is… Surface
A one-line fact, or narrative Prose
≤8 rows · ≤4 metrics · one chart Inline card
>12 rows · several sections · scrolled/sorted/kept Side panel
A deliverable they'll keep Saved outputs/*.jsx
Something they must act on React + @cc/ui
Genuinely could be either Ask (optionPicker + hitl)

Two tests that settle most cases: would this need its own scrollbar inside a chat bubble? (→ panel), and am I about to type a markdown table? (→ render it).

The rule goes in the always-injected addendum (~7 lines) because it has to fire unprompted; the fuller table lives in design.md, which loads on demand. load_artifact_kit's docstring now says outright to reach for it without being asked whenever real data is about to be presented.

Verification

Check Result
Python unit tests 2472 passed, 8 skipped
vitest 63 passed (9 new for panel width)
tsc --noEmit clean
eslint clean
next build passes
Browser drag-across-iframe pass (table above)

New tests cover clamping, non-finite input, snapshot stability (React #185), no-emit when pinned at a bound during a drag, width surviving open/close, storage round-trip, out-of-bounds stored values, and unreadable storage.

Note

The drag was verified as an isolated browser repro of the exact handler pattern, not by driving the real panel — that needs an authenticated session with open documents. The store logic underneath it is unit-tested directly.


Generated by Claude Code

claude added 2 commits July 28, 2026 03:21
Two reported bugs in the document panel.

The close button on each tab was `opacity-0` until you hovered that exact tab,
so on touch it was unreachable entirely and on a pointer device you had to
already know it was there. It is now always rendered — dimmed at rest, full
strength on hover/focus — with an aria-label and a visible focus ring.

The panel was a fixed `w-[30rem]`, so a wide table or dashboard had nowhere to
go. Its right edge is now a drag handle: pointer to resize, double-click to
reset, arrow keys (Shift for a coarse step) for keyboard users, exposed as a
role="separator" with proper aria-value* state. Width is clamped to 320–1100px
and persisted per browser; content reflows because the panel was already flex
throughout.

Two things worth recording:

- The handle uses pointer CAPTURE, not the window-listener pattern used
  elsewhere in the app (TimeGrid). It has to. This panel renders artifacts in an
  iframe, and a window listener stops receiving pointermove the instant the
  cursor crosses it. Measured on the same 200px drag: capture recorded 9 samples
  and resized 480px -> 280px; window listeners recorded 1 sample and the panel
  never moved.

- Width lives in sidePanelStore but is NOT seeded from localStorage at module
  load, because that module is evaluated during SSR too and would desync
  hydration. hydratePanelWidth() applies it after mount, which is invisible
  since the panel starts closed.

openDoc built its next state without spreading the previous one, so adding width
to the store would have silently reset a user's resized panel every time a
document opened. Fixed, and pinned by a test.
The kit existed but nothing told an agent when to reach for it. The guidance said
"reach for UI eagerly" with no threshold, and said nothing at all about inline
card vs side panel — so the default stayed a markdown table in chat regardless of
how much data there was.

Add a routing rule keyed on VOLUME, with numbers rather than judgement calls,
since vague advice does not change behaviour:

  prose            a one-line fact, or a narrative answer
  inline card      <=8 rows / <=4 metrics / one chart — seen at a glance
  side panel       >12 rows, several sections, or anything scrolled/sorted/kept
  React + @cc/ui   the user must act on it, or it is too big to stay readable
  ask              a big result that could be a saved doc OR a live dashboard

Two tests that settle most cases: would this need its own scrollbar inside a chat
bubble (then it belongs in the panel), and am I about to type a markdown table
(then render it instead).

The rule goes in the always-injected addendum because it has to fire without
being asked; the fuller table lives in design.md, which loads on demand.
load_artifact_kit's docstring now says outright to reach for it unprompted
whenever real data is about to be presented.
@vjvarada
vjvarada merged commit 299c0aa into main Jul 28, 2026
7 checks passed
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