Skip to content

feat: unify session right utility panel#32

Merged
Astro-Han merged 21 commits into
devfrom
codex/feat-right-utility-panel
Apr 19, 2026
Merged

feat: unify session right utility panel#32
Astro-Han merged 21 commits into
devfrom
codex/feat-right-utility-panel

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Apr 18, 2026

Copy link
Copy Markdown
Owner

Summary

  • unify the desktop session right utility panel into four top-level tabs: Status, Files, Review, and Terminal
  • move terminal rendering into the right panel on desktop while preserving the existing bottom terminal flow on mobile
  • keep Status routed to the right panel on desktop, preserve the existing status popover on mobile, and add legacy restore coverage for old sidePanelTab: "changes" state

Why

This slice lands the next phase of the PawWork desktop UI tracked in #26. The goal is to replace split session utilities with one consistent right-side surface without regressing terminal access, status access, or old persisted layout state.

Related Issue

Part of #26

How To Verify

cd packages/app
bun test --preload ./happydom.ts ./src/pages/session/right-panel-tabs.test.ts ./src/context/layout.test.ts ./src/components/status-panel.test.tsx ./src/pages/session/session-side-panel.test.tsx ./src/i18n/parity.test.ts
bun run typecheck
bun x playwright test e2e/files/file-tree.spec.ts e2e/commands/panels.spec.ts e2e/status/status-popover.spec.ts e2e/prompt/prompt-slash-terminal.spec.ts e2e/terminal/terminal-init.spec.ts -g "(@smoke file tree entrypoints can open the panel and a file|desktop side-panel buttons switch between review and files within a unified right-panel tab shell|legacy changes side-panel state restores into the review tab|session status button opens the right-panel status tab|session status tab can switch to mcp|session status button toggles the right panel closed|mobile session status button still opens the status popover|/terminal opens the right-panel terminal tab|mobile /terminal opens the bottom terminal panel|@smoke terminal mounts and can create a second tab)"

Screenshots or Recordings

  • Not attached in CLI flow. Visible behavior is covered by the targeted Playwright checks above.

Checklist

  • I ran the relevant verification steps
  • I tested visible changes manually when needed
  • I am targeting the dev branch

Phase 2 — shell geometry refine (added 2026-04-19)

Reworks the outer shell introduced above. The first pass left the panel growing with the viewport and using the wrong tab chrome; this follow-up anchors it at a fixed width, rebuilds the tab row per docs/design/src/rightpanel.jsx, deletes the zombie middle splitter, and wires a real left-edge resize handle.

Changes

  • Right panel anchors at 340px default, resizes 300–520 via a left-edge ResizeHandle. Session column flips to flex-1 and claims the gap.
  • New variant="sidepanel" on @opencode-ai/ui/tabs with scoped CSS: disables the base ::after flex-grow filler so the 40px right-gutter reserve stays flush; neutralizes wrapper chrome; owns trigger color/bg/selected contract (consumer keeps geometry via Tailwind).
  • Global layout.rightPanel.width (sibling of layout.sidebar, layout.fileTree, layout.session). clampRightPanelWidth guards undefined / non-number / non-finite inputs and defaults to 340. Width persists across sessions and reloads.
  • Deletes sessionPanelWidth + desktopSidePanelOpen memos and the middle splitter <Show> block in session.tsx (0 remaining readers). Removes transition-[width] on the now-flex session column (no-op on flex-1).
  • size.start/touch threaded through the new handle to preserve transition suppression during drag, matching the sidebar + file-tree handles.

Test coverage

  • clampRightPanelWidth pure-factory: default / inside-range / below-min / above-max / NaN / ±Infinity / string / null → 9 cases.
  • formatRightPanelWidth + makeRightPanelResizeHandler helpers: closed/open, touch-before-resize, clamping delegation.
  • tabs.test.tsx type-level: union includes "sidepanel" (typecheck gates regression; bun strips types at runtime).
  • E2E e2e/session/right-panel-width.spec.ts: open via titlebar → drive layout.rightPanel.resize(400) via DEV-only window.__pawworkLayout hook → reload → assert 400px persisted.

Review

Three rounds of multi-model crosscheck (Claude + Codex) converged:

  • R1 fixed: clamp non-finite guard, tabs-list specificity override (base [data-slot="tabs-list"] { height: 48px } at (0,2,0) was winning over consumer's h-11 at (0,1,0)), removed dead transition-[width] on flex-1.
  • R2 strengthened: typeof !== "number" guard on clamp, inline comment on the 40px reserve spacer.
  • R3 only surfaced pre-existing wantsReview tab-gating (out of scope; this PR only renames "changes""review" in that expression) and false-positives disproved by code reading.

Known tradeoffs

  • Tests at the component layer stay at the null-mock pattern (session-side-panel.test.tsx). bun test compiles JSX with the React transform, not Solid's — real DOM rendering would require adding @solidjs/testing-library + harness setup to packages/ui and packages/app, which is scope creep on the upstream-synced test infra. Coverage shifts to pure helpers (formatRightPanelWidth, makeRightPanelResizeHandler, clampRightPanelWidth) + typecheck + e2e.
  • E2E drives the store via window.__pawworkLayout (DEV-gated), not the pointer drag path. Playwright webServer always runs in DEV, so the hook is always present in practice; a real-drag test would be more brittle and cover less surface than the persistence round-trip.
  • layout.session.width is still persisted as a store field even though nothing reads it anymore — kept for layout.v6 blob back-compat; a future migration can strip it.

Scope deferred

  • wantsReview / openReviewPanel change-tab behavior (pre-existing; flagged by both reviewers but unchanged by this diff).
  • Sidepanel variant's trigger selectors are not scoped to direct-child Tabs.List — consistent with the existing pill variant, which has the same descendant-leakage pattern; fixing both belongs in a broader tabs primitive refactor.

Adds "sidepanel" to the TabsProps variant union and scoped CSS that:
- disables the [data-slot="tabs-list"]::after flex-grow filler so the
  consumer's 40px right reserve stays flush against the panel edge
- neutralizes the base tabs-trigger-wrapper chrome (borders, bg, selected
  tint) so consumers can style triggers via Tailwind
- owns the color/bg/state contract on [data-slot="tabs-trigger"]:
  text-weak default, text-base selected, surface-raised-base selected bg,
  surface-base-hover on hover

Type-level test asserts the union accepts "sidepanel" (tsc gates the
regression; bun strips types at runtime so the test is a compile-time
check in practice).
- DEFAULT_RIGHT_PANEL_WIDTH=340, MIN=300, MAX=520 (exported)
- clampRightPanelWidth pure factory (exported for unit coverage)
- rightPanel seed { width: 340 } in createStore
- layout.rightPanel accessor with clamped width() memo and resize()

Persisted store round-trips existing layout.v6 keys; missing rightPanel
in legacy blobs falls back to the seed default, then clamp guards.
- Export pure formatRightPanelWidth(open, width) helper for unit coverage
- panelWidth now sources from layout.rightPanel.width() instead of
  calc(100% - session.width), so the panel stops growing with viewport
- Tabs root uses variant="sidepanel" (tabs.css scoped block handles the
  wrapper-chrome reset + trigger color/bg contract)
- 40px aria-hidden reserve div appended inside Tabs.List so the tab row
  has a right margin matching the design prototype

DOM-render tests sit outside bun's Solid-incompatible JSX transform; the
existing mock-to-null harness stays, with typecheck gating variant prop
type and Task 6 e2e gating the end-to-end width wiring.
…emos

With the right panel now self-sized via layout.rightPanel (Task 2-3),
the middle splitter that resized layout.session is a zombie. This commit:

- deletes desktopSidePanelOpen + sessionPanelWidth (no readers left)
- flips session column wrapper from "shrink-0 md:flex-none + inline
  style.width" to "min-w-0 flex-1" so it claims the gap between
  sidebar and right panel on all breakpoints
- removes the <Show when={desktopReviewOpen()}>...<ResizeHandle> block
  that drove layout.session.resize

size.active() still threads through (terminal panel + transition class
on the session column), size.start/touch still fire from the file-tree
handle inside Review tab; Task 5 adds the new left-edge handle that
also participates.
- Export makeRightPanelResizeHandler pure factory — touch-then-resize
  callback is unit-testable outside Solid's DOM.
- Mount a wrapper <div onPointerDown={size.start}> above the tab shell,
  with a ResizeHandle (edge="start", direction="horizontal") attached
  to the aside's left edge. min=300 max=520.
- Clamping stays centralized in layout.rightPanel.resize (Task 2), so
  the handler forwards the raw width straight through.

pointerdown + ResizeHandle prop binding are typecheck-gated; Task 6's
e2e drives the persist round-trip end-to-end.
- Expose the provider's layout object on window.__pawworkLayout under
  import.meta.env.DEV so e2e can drive layout.rightPanel.resize(n)
  without wiring a fake pointer-drag sequence.
- New spec at e2e/session/right-panel-width.spec.ts: open the panel
  via the titlebar toggle, set width to 400, reload, verify persisted.

Covers the unit gap where Task 5's mock doesn't exercise the real
Solid store + persisted() round-trip.
Per crosscheck codex P2: a malformed persisted blob or a buggy drag
math path could hand clampRightPanelWidth a NaN or ±Infinity, and
Math.max/Math.min would propagate that straight through to the style
width, producing "NaNpx" or "Infinitypx".

Treat non-finite like undefined — fall back to DEFAULT. Two new test
cases exercise NaN / ±Infinity.
Two crosscheck findings:

- tabs.css sidepanel variant: the base [data-component="tabs"]
  [data-slot="tabs-list"] { height: 48px } rule has (0,2,0) specificity
  and wins over the consumer's Tailwind h-11 (0,1,0). Explicit
  height: 44px in the variant block makes the 44px tab row intentional
  instead of a specificity accident.
- session.tsx: the column is now flex-1 with no explicit width, so
  transition-[width] no longer animates anything (flex-basis changes
  on flex-1 items don't go through the width transition). Strip the
  classList entirely; the aside's own transition-[width] at
  session-side-panel.tsx:315 is what actually smooths the open/close.
Round 2 crosscheck:

- clampRightPanelWidth now rejects non-number inputs (string, null)
  in addition to the non-finite guard added in round 1. Corrupted
  persisted blobs from external migration tooling could hand the
  function a string; treat like undefined → default. Two new test
  cases cover string + null.
- session-side-panel.tsx: add inline comment explaining why the 40px
  aria-hidden spacer inside Tabs.List exists (matches design prototype
  gutter) so a future editor doesn't trim it as dead space.
Two manual-test regressions:

- reviewSnap suppressed the 240ms transition on every desktopReviewOpen
  flip. That guard was there to prevent double-animation jank back when
  the session column had an explicit sessionPanelWidth() that animated
  alongside the aside. With Task 4 the session column is pure flex-1,
  so the aside is the only thing animating and the session column
  auto-adjusts per frame. reviewSnap state + requestAnimationFrame
  bookkeeping + prop are all removed.
- Right panel titlebar toggle SVG rendered at the button's 32x24 box
  because the bare <svg data-slot="icon-svg"> matched icon.css's
  [data-slot="icon-svg"] { width: 100% } without the sizing wrapper.
  Wrap in <div data-component="icon" data-size="small"> to pin it at
  16x16, matching the left sidebar toggle.
Two @smoke titles drifted after earlier PR #32 commits:
- home server picker title → "@smoke project home status panel can open the server picker dialog"
- file-tree entrypoint title → "@smoke review keeps the persistent file-tree pane for review navigation"

The curated list in e2e-smoke-tagging.test.ts still referenced the old
titles, failing the CI unit job. Sync to current titles.
@Astro-Han Astro-Han merged commit abaafe6 into dev Apr 19, 2026
8 of 9 checks passed
@Astro-Han Astro-Han deleted the codex/feat-right-utility-panel branch April 19, 2026 10:21
Astro-Han added a commit that referenced this pull request May 9, 2026
Slice 10 of issue #440 — full composer + dock + model picker rewrite. 38 commits squashed.

## Highlights

- **Composer joined-card shell**: new DockCard + DockSegment primitives in @opencode-ai/ui, dock widgets share the L34 joined-card layout. Hybrid composer shell renders permission UI inline.
- **prompt-input.tsx 1602 → ~570 lines**: split into prompt-input/{store-types, editor-serialize, editor-imperatives, comment-routing, history-navigation, popover-controllers, editor-input, keydown, model-controls}. No behavior change — all factories injected via deps; popover↔editor-input cycle resolved via mutable forward-ref.
- **DockWidgetHeader unified**: single 36px collapsed header with 30px chev IconButton (3+3 breathing) — consolidates Followup, Revert, Todo widgets.
- **DOCK_MOTION contract**: composer/dock/widgets share `{ visualDuration: 0.3, bounce: 0 }` via packages/app/src/pages/session/composer/motion.ts. Aligns with global useSpring convention.
- **Model picker rewrite**: variant control merged into model trigger; thinking-level promoted from separate popover to inline section. `[data-picker-content]` guard prevents nested popover dismiss.
- **Send button + ContextUsage parity**: 30px outer / 16px inner glyph, theme-locked colors, larger stop glyph.
- **Followup defaults to queue mode**: drops the legacy steer override.
- **Control heights**: single 30px dense tier across composer; sidebar trailing actions step down to 26 per nesting rule.
- **Picker contracts**: tighter padding, narrower width (340), dropped sticky labels.

## Post-review fixes (CI + 2 review rounds)

- Critical: DockCard manual classList merge (single key with spaces) was throwing DOMException InvalidCharacterError — replaced with Solid native class+classList co-rendering.
- CI typecheck: PopoverControllers types aligned with upstream solid-list `Accessor<string | null>` (was `undefined`).
- CI e2e: dropped stale `prompt-variant-control` selector (variant folded into model picker).
- Send button e2e contract: 32 → 30px to match dense tier.
- Editor serialize: fixed double-newline regression for blank rows.
- Keydown: removed dead `if (stopping())` branches (both arms identical).
- Custom slash command id includes source to prevent workspace+user collision.
- Model trigger label: pinned to `min-w-[80px] max-w-[180px]` to balance short names ("GPT-5.5") vs long ("DeepSeek V4 Pro") without jiggling the workspace control.

## Systemic finding (tracked under #34)

PawWork sets `html { font: var(--type-body) }` → root font-size 13px. This means **Tailwind rem-based units render at 81% of their pixel-named values**: `h-9` = 29.25px, not 36px. Multiple visual bugs in this slice (chev clipping, header height) traced back to this. Remediation: use absolute pixels (`h-[36px]`) for grid-pinned heights; avoid Tailwind's pixel-named scale for 4pt-grid alignment. DESIGN.md docs debt to be filed under #34.

## Hand-test backlog (open after merge)

- #32 dark theme regression sweep
- #33 single-prompt E2E

## Files of note

- packages/ui/src/components/dock-card.tsx (new)
- packages/app/src/pages/session/composer/{motion.ts, use-dock-collapse.ts, dock-widget-header.tsx, session-composer-region.tsx, session-todo-dock.tsx}
- packages/app/src/components/prompt-input/* (9 new files)
- packages/app/e2e/composer/composer-slice-10.spec.ts (new)

Closes part of #440.
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.

1 participant