feat(ui,app): sidebar L35 row rewrite (slice 09, issue #440)#495
feat(ui,app): sidebar L35 row rewrite (slice 09, issue #440)#495
Conversation
Drop the dead ProjectIcon export (no callers) and rewrite the row outer shell + right-slot status system to the L35 lock: - Row: fixed h-8, padding 0 10, radius-sm, flex-centered (no leading-[1.4]) - Active title: text-fg-strong + font-medium - Status: 4 states asking | busy | error | time, exclusive, opacity-faded on hover/focus/menu-open (never display:none) - Drop the unseen / pinned-icon visual states; pin signal is now carried by the Pinned section position, unread signal is dropped (L35 lock has no slot for it) - Drop SessionItemProps.pinned and SessionItemProps.dense; menu state reads pinnedIDs() directly in pawwork-sidebar - Sub-session indentation kept at level*16 as a deliberate departure
…ayout - Replace icon-only sort toggle with text+chev DropdownMenu (排序 / Sort) exposing two options 按时间 / 按项目 (By time / By project) per L35. - Add data-component "pawwork-side-traffic" 32-high placeholder per L37 so slice 17 can drop in macOS traffic-lights and the collapse button without reflowing geometry. - Add data-component "pawwork-side-top|side-scroll|side-foot" markers on existing segments so the L37 four-segment shape is testable. - Inline ⌘, hint in footer (drops TooltipKeybind wrapper); kbd container styling lands in slice 14. - Render Rename ↵ / Delete ⌫ shortcut hints in dropdown + context menus.
…deeper) Slice 08 collapsed Icon size=small to 16px globally, so the leading-slot 14px override is no longer load-bearing — drop it and let NewSessionItem render at the standard chrome size. Add a row-scoped rule that bumps the action-overlay iconbtn radius to md and its hover to --row-active-overlay (0.06 alpha, one step deeper than the row's 0.04) so it reads as its own target on a hovered row.
Add sidebar-slice-09.spec.ts covering: - 4-state right-slot status + 4-item menu with Rename ↵ / Delete ⌫ hints - Sort trigger as text+chev popover with two options (按时间/按项目) - L37 four-segment shape (side-traffic 32 above side-top, side-foot) Patch sidebar-session-organization.spec.ts to use the new pawwork-sort-trigger selector and pick the project option from the popover instead of toggling the old ghost icon button.
PawWork sets root font size to 13px (dense desktop), so Tailwind h-N sizing scales rem-based and h-8 ends up 26px instead of L35's 32. Pin the row outer (h-32), status slot (20×20), Spinner (16×16), and side-traffic placeholder (h-32) to literal pixels. Drop the now-stale pin-button-in-leading-slot assertion in sidebar-leading-slot.spec.ts: per L35 the row no longer renders a pin glyph, so pin status is signaled by the row's presence in the Pinned section (still asserted).
📝 WalkthroughWalkthroughThis PR consolidates sidebar session indicators into a four-state status model (asking/busy/error/time), adds icon fields to session menu actions, standardizes UI component heights to 32px, restructures the sidebar into three layout sections with keybind-aware controls, refactors the spinner to CSS animation, and adds supporting tests and translations. ChangesSidebar Status Model, Menu Icons, and Layout Restructuring
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related issues
Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request refactors the sidebar geometry and session row status logic to align with updated design specifications. Key changes include removing the pin glyph from individual rows, replacing the sort toggle with a dropdown menu, and introducing a centralized 4-state status indicator (asking, busy, error, or time). Additionally, keyboard shortcut hints have been added to session actions and the settings button, and several components were updated to use fixed pixel heights for consistent layout. Review feedback suggests converting remaining rem-based heights to fixed pixel values and aligning the settings button styling with the new 32px row specification.
Slice 08 (#440, 6889e01) collapsed the Icon multi-size API to a fixed 16x16 contract; `size` is no longer in IconProps. Strip the leftover `size="small"` from 7 sidebar callsites that survived our pre-merge state. Functionally a no-op since the rendered size was already 16x16, but the prop is now a TS error.
The L37 spec reserved a 32px top segment so slice 17 (which moves the macOS traffic-lights into the sidebar) wouldn't reflow geometry. While the OS still paints traffic-lights on its window chrome, the placeholder just doubles the empty space at the top. Drop it; slice 17 will reintroduce the segment when it actually fills it. Updates the four-segment e2e to the three-segment shape it reflects.
The slice 09 footer button rendered an inline `⌘,` next to its label, while the new-session and search buttons above it had no shortcut hint at all. Three buttons in the same column with three different shortcut treatments looks unintentional. Wrap all three with TooltipKeybind so the keybind reveals on hover with the same affordance everywhere; thread `newSessionKeybind` and `searchKeybind` props from layout.tsx, mirroring `settingsKeybind`. Pure visual consolidation; behavior unchanged.
The right-slot status box was forced to a 20×20 square (`size-[20px]`), which fits icons (16px) but truncated time text like "刚刚" into a vertical character stack on narrow sidebars. Switch the inner box to `h-full min-w-[20px]` so it stays icon-sized when the slot holds an Icon/Spinner but flexes horizontally for text content. Add `whitespace-nowrap` on the time `<span>` as belt + suspenders.
The new-session, search, and settings buttons rendered at ~30px (`py-1.5` + content), while the L35 session row was 32px. The 2-4px gap looked unintentional in a single sidebar column. Treat the whole sidebar as one nav system: lock all three top/foot buttons + the session row to 32px, matching the L30/L31 list-row family. This is a deliberate sidebar-scoped deviation from L24 buttons 28 (which still applies to dialog/composer/form Buttons elsewhere — those never sit next to list rows so the visual conflict doesn't arise). Same shape Codex.app uses with its single `--height-token-nav-row` token.
L35 locks row hover/active transition at `var(--duration-fast)` (80ms) ease-out. The Tailwind `transition-colors` shorthand defaulted to 150ms cubic-bezier — close, not exact. Add a CSS rule scoped to the row so hover/active state changes feel as snappy as the spec asks.
L35 specifies section labels ("已置顶", "全部会话", project group headers)
and the sort trigger as `--type-caption` (= 400 13px/130%, 500 weight on
the trigger). The implementation used `text-12-regular` / `text-12-medium`
(12px), shaving 1px off the font and breaking visual rhythm against the
13px row title.
Lock all four section headers to `text-13-regular` and the sort trigger
to `text-13-medium`, plus pin the trigger height to a literal `h-[24px]`
to avoid drift if base font-size ever shifts.
The section header bands ("已置顶", "全部会话" + sort trigger, project
group titles in project-mode) used `mt-3 + pt-3 + pb-2` ad-hoc spacing
that summed to ~44px — out of step with the 32px nav-row rhythm we just
unified across the rest of the sidebar.
Lock all three to `h-[32px] flex items-center px-2` so every visual unit
in the sidebar lives on the same 32-grid. Section headers still read as
non-clickable separators (no hover background, fg-weak text). The sort
trigger remains h24 nested inside, centered with breathing room above
and below.
Locking section headers to 32 left them visually equidistant from the section above and the rows inside, which made the eye read them as "another row" rather than a separator. Apply the affinity-spacing rule (Refactoring UI): - mt-4 (16px) above each section header — clear gap from the previous group, signaling "new section" - 0 below — header sits tight against its own rows so the eye groups them as one unit (Gestalt proximity: header belongs to what's below) - first:mt-0 (or index() === 0 inside <For>) so the first visible section doesn't get a stray top gap Drops nav's `gap-1` since headers now own their breathing room explicitly. Inner section gap-0.5 (2px between sibling rows) preserved.
Dropping `first:mt-0` revealed why first-child guarding was wrong here: the user's view had no pinned section, so "全部会话" became the first nav child, took mt-0, and stuck to the search button above with no section break. The side-top button cluster and the first scroll section ARE different groups — affinity spacing demands the same 16px gap there as between any two sidebar sections. Always apply mt-4 to top-level section header containers (pinned, "全部会话"). Project group sections inside the For keep `index() === 0` guarding because the first group should attach to its containing "全部会话" header (header-to-content tight).
The 1px `border-t` between scroll content and the settings footer was doing the same job as a section break — but with a hard line instead of breathing room. Now that affinity spacing carries every other section boundary in the sidebar, the divider is redundant noise. Replace `border-t border-border-weaker px-3 py-2` with `px-3 pt-4 pb-3` (16 above, 12 below — same 16px rhythm as section headers, 12 mirrors side-top's pt-3 for top/bottom symmetry to the sidebar edges).
Lift Button, TextField (normal variant), Picker trigger, and line-comment action default heights from 28 to 32 so primitive defaults align with the L35 sidebar rhythm (rows / pinned / settings all at 32) and Picker dropdown items (already at 32). This removes the trigger-to-item height jump and unifies the read / select / act surfaces. No size API added. The remaining 28 hardcoded usages are all on surfaces scheduled for removal or rework: dialog-select-model and dialog-manage-models move to settings Models pane per design preview, right-side panel tabs and session-new-view skill chips are pending separate redesigns.
213d8d4 to
397a678
Compare
The L35 right-slot asking state previously only tracked PermissionRequest, which left an agent ask() pause showing as busy in the sidebar while the main region correctly surfaced a question. Mirror the use-session-blockers OR set (permission || question-blocker || question) so the sidebar matches the main region semantics. Renames hasPermissions to isAsking to make the intent legible at the call site.
The legacy 16-square pulse animation was an opencode-era brand mark and conflicts with the PawWork loading affordance shown in design preview (docs/design/ui_kits/desktop/styles.css L1407). Replace with a rotating ring built on a single div + border-right transparent + 700ms linear rotate. Keeps the component contract (class / classList / style passthrough, currentColor, 18px default), so all four call sites (sidebar row busy state, sidebar workspace, dialog-connect-provider, message-timeline) pick up the new look without changes.
Three drifts from the just-replaced ring against STANDARDS.md L60/L65/L128: - Keyframe was spinner-rotate; the L65 lock names pw-spin as the canonical 700ms rotation reused by spinner and the thinking ring. - Border was 1.4px (carried from the design preview todo spinner at 7px); L60 calls for 1.5px equivalent at 16. - Sidebar busy slot rendered the spinner at 16; the keyshape circle is Ø18 inside the 20x20 status box per L60/L128.
8539005 to
91c511f
Compare
Dropdown items were rendering at ~26px (padding 4 + line-height-large), out of step with the L30 menu / L35 sidebar / picker item 32 row family. Pin min-height to 32 with horizontal padding 0 8, and swap the highlighted background from --surface-raised (solid panel tone) to --row-hover-overlay so menu / picker / list-row hovers all share the same overlay token.
The Rename ↵ / Delete ⌫ hint string was a placeholder — no command.keybind ever wired session.rename or session.delete, so the suffix advertised shortcuts that did nothing. Drop the shortcut field entirely (do not lie to users) and add a leading icon per row instead (pin / pencil-line / download / trash). Exports IconName from @opencode-ai/ui/icon so the action type can constrain icon to the registry instead of a free-form string. Updates the unit test to assert the icon registry order, and the e2e spec to assert leading icon-svg slots on each menuitem.
Sort menu was the only place left with naked text labels; align it with the session menu icon convention. Use schedule for By time and folder for By project as leading slots, and swap the active-state ✓ glyph for the check icon so the row carries icons in both leading and trailing slots.
Replace the text + chevron-down trigger with a 24x24 ghost IconButton wrapped in a Tooltip; brings the sort affordance under the L23 secondary-control standard so it sits inside the 32 section header with 4px breathing room above and below, matching the row menu IconButton elsewhere in the sidebar. Uses sliders as a placeholder icon (chevron-grabber-vertical reads as drag-handle, sliders is the closest fit in the current registry). A dedicated sort icon is queued in pawwork-chrome-icon-imagegen-v4-slice08 Sheet 12; swap once that lands.
Trace cell #1 of imagegen Sheet 12 (decreasing list bars + leading down arrow, sort-desc semantics). Rescaled to landscape keyshape 18×14 inside the 20×20 viewBox (1-unit margin preserved). Replaces the sliders placeholder on the sidebar sort trigger.
Two visual mismatches between the sidebar sort trigger and the session-row dot-grid trigger: - Hover affordance: row-menu uses radius-md + --row-active-overlay while the sort trigger fell back to the IconButton default radius-sm + --hover-overlay, reading visibly faded next to it. Share the row-menu rules. - Sort glyph: previous transform was height-bound at 14 inside the 20×20 viewBox, leaving the width at 17.43 and the icon looking smaller than dot-grid (which spans ~16). Re-scale width-bound to 18 so both axes use the live area; height becomes 14.46, still inside the 18×18 live area.
Previous commit pulled the sort trigger onto the row-menu hover treatment (radius-md + 6% black) and stretched the sort glyph width-bound past the landscape 14-unit height ceiling. Both moves violate the standards: - L23 says IconButton ghost is one variant: radius-sm + 4% black. L35 explicitly carves out the row action-overlay (radius-md + 6%) with a written reason — the row already has a 0.04 hover layer to sit on top of. The sort trigger lives outside the row, so the L23 default is the correct fit. - L21 keyshapes are bounding boxes, not minima. Landscape is 18×14. The traced glyph aspect (1.245) is slightly squarer than 18×14 (1.286), so the height-bound scale (17.43×14) is what fits inside the keyshape; width-bound (18×14.46) overflows it. The cosmetic delta versus dot-grid is by design: row-menu and sort/footer iconbuttons are two different control classes.
Two control classes (default 4% / radius-sm vs sidebar action-overlay 6% / radius-md) collapse into one. Visual differences inside the same sidebar (sort trigger vs row-menu) violated the elegance rule, and the written L35 carve-out shifted the judgment call onto every future contributor adding an IconButton. Changes: - icon-button.css: hover token --hover-overlay -> --row-active-overlay (4% -> 6%), radius --radius-sm -> --radius-md - sidebar.css: drop the row-menu override block; the new default matches it - pawwork-sidebar.tsx: drop the now-redundant class="rounded-md" on the row-menu trigger - icon-button-states.test.ts: track the token rename Token reuse: --row-active-overlay (named for list rows) is shared by IconButton hover at the same 6% tier — avoids minting a synonym token like --control-hover-overlay. Button (secondary/danger) hover stays on --hover-overlay (4%) because dialog/composer/form contexts read fine at 4% and a heavier overlay would compete with the surrounding content. STANDARDS.md L23 + L35 updated locally (untracked spec doc) to reflect the unified rule.
Session row pads 10px left/right per L35, but every adjacent surface fell back to a slightly tighter rhythm: - pinned label / sort header / project group header: px-2 (8px) - show-more / search-history rows: px-2 (8px) - new-session / search / settings buttons: pl-2 pr-3 (8/12) That left a 2px stair-step between section headers and the rows they introduce, and made the sort trigger sit 2px inboard of the row-menu trigger directly below it. Snap every container to px-2.5 (10px) so the entire sidebar shares one inner edge.
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (3)
packages/app/src/pages/layout/session-menu-actions.test.ts (1)
30-30: 💤 Low valueLGTM — icon order assertion locks down the 4-state model.
The icons for the
exportAvailable: falsecase (["pin", "pencil-line", "trash"]) are not asserted in the second test; consider adding that assertion there too for full regression coverage.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/app/src/pages/layout/session-menu-actions.test.ts` at line 30, The second test in session-menu-actions.test.ts doesn't assert the icons when exportAvailable is false; update that test (the one that sets exportAvailable: false / exercises the actions variable) to include an assertion that actions.map(action => action.icon) equals ["pin", "pencil-line", "trash"] so the 3-state icon order is locked down for regression coverage.packages/ui/test/icon-button-states.test.ts (1)
64-65: ⚡ Quick winScope this assertion to the hover rule to avoid false positives.
Right now the test only checks token presence anywhere in CSS, so it can pass even if hover mapping regresses.
Proposed test hardening
- test("hover applies --row-active-overlay token", () => { - expect(CSS).toContain("var(--row-active-overlay)") - }) + test("hover applies --row-active-overlay token", () => { + expect(CSS).toMatch( + /&:hover:not\(:disabled\)\s*\{[^}]*background-color:\s*var\(--row-active-overlay\)/ + ) + })🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/ui/test/icon-button-states.test.ts` around lines 64 - 65, The test "hover applies --row-active-overlay token" currently only checks for the token anywhere in CSS; update it to assert the token appears specifically inside the hover rule so it fails on regressions. In the test in icon-button-states.test.ts, change the expectation to look for a hover selector combined with the token (e.g., match a substring or regex that includes a :hover (or the component hover selector used in the styles) followed by var(--row-active-overlay)) so the assertion ensures the hover rule itself contains the token rather than the token existing elsewhere.packages/ui/src/components/spinner.css (1)
10-10: ⚡ Quick winAdd a reduced-motion fallback for spinner animation.
The new infinite rotation should respect
prefers-reduced-motion.Proposed accessibility-safe tweak
[data-component="spinner"] { @@ animation: pw-spin 1200ms linear infinite; } + +@media (prefers-reduced-motion: reduce) { + [data-component="spinner"] { + animation: none; + } +}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/ui/src/components/spinner.css` at line 10, The spinner's infinite rotation declared by the animation property using "pw-spin" should respect the user's prefers-reduced-motion setting; update the spinner.css rule that sets animation: pw-spin 1200ms linear infinite to include a prefers-reduced-motion media query that disables or replaces the animation (e.g., set animation: none or a non-animated fallback and ensure visibility/placement is preserved) so users who request reduced motion won't see the continuous rotation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/app/e2e/sidebar/sidebar-slice-09.spec.ts`:
- Line 23: Replace the brittle boolean assertion that checks count (the variable
named count in sidebar-slice-09.spec.ts) with a containment assertion so
failures show the actual value; specifically, change the line using expect(count
=== 3 || count === 4).toBe(true) to an assertion that expects the allowed values
array to contain count (e.g., expect([3, 4]).toContain(count)) so test output
reports the actual count on failure.
- Around line 82-86: Split the combined null-guard + ordering assertions into
explicit null checks followed by value comparisons: first assert that topBox,
scrollBox, and footBox (results of boundingBox() calls) are not null (e.g.,
expect(topBox).not.toBeNull()), then dereference their .y values and assert
ordering with comparisons (e.g., expect(topBox.y).toBeLessThan(scrollBox.y) and
expect(scrollBox.y).toBeLessThan(footBox.y)). Update the test around the
boundingBox() results (topBox, scrollBox, footBox) to perform these two-step
checks so failures indicate which element was null versus a positional ordering
failure.
In `@packages/ui/src/components/icon-button.css`:
- Line 8: Revert the global border-radius change in
packages/ui/src/components/icon-button.css (remove the line setting
border-radius: var(--radius-md) on the base icon-button) and instead apply the
radius only to the sidebar row menu trigger by adding a scoped rule that targets
the selector with data-component="pawwork-session-row" and
data-action="session-row-menu" applied to the icon-button (i.e.,
[data-component="pawwork-session-row"]
[data-action="session-row-menu"][data-component="icon-button"]), setting its
border-radius to var(--radius-md).
In `@packages/ui/src/components/spinner.tsx`:
- Around line 3-12: The Spinner component currently hardcodes
aria-label="Loading"; add an optional ariaLabel prop to Spinner's props
(ariaLabel?: string) with a default value "Loading", and use that prop for the
div's aria-label instead of the literal so callers (e.g., sidebar-items.tsx) can
pass language.t(...) translated strings while preserving existing behavior when
no prop is supplied.
---
Nitpick comments:
In `@packages/app/src/pages/layout/session-menu-actions.test.ts`:
- Line 30: The second test in session-menu-actions.test.ts doesn't assert the
icons when exportAvailable is false; update that test (the one that sets
exportAvailable: false / exercises the actions variable) to include an assertion
that actions.map(action => action.icon) equals ["pin", "pencil-line", "trash"]
so the 3-state icon order is locked down for regression coverage.
In `@packages/ui/src/components/spinner.css`:
- Line 10: The spinner's infinite rotation declared by the animation property
using "pw-spin" should respect the user's prefers-reduced-motion setting; update
the spinner.css rule that sets animation: pw-spin 1200ms linear infinite to
include a prefers-reduced-motion media query that disables or replaces the
animation (e.g., set animation: none or a non-animated fallback and ensure
visibility/placement is preserved) so users who request reduced motion won't see
the continuous rotation.
In `@packages/ui/test/icon-button-states.test.ts`:
- Around line 64-65: The test "hover applies --row-active-overlay token"
currently only checks for the token anywhere in CSS; update it to assert the
token appears specifically inside the hover rule so it fails on regressions. In
the test in icon-button-states.test.ts, change the expectation to look for a
hover selector combined with the token (e.g., match a substring or regex that
includes a :hover (or the component hover selector used in the styles) followed
by var(--row-active-overlay)) so the assertion ensures the hover rule itself
contains the token rather than the token existing elsewhere.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 8615c657-b734-4622-9175-6b8fc80fd6e8
📒 Files selected for processing (23)
packages/app/e2e/sidebar/sidebar-leading-slot.spec.tspackages/app/e2e/sidebar/sidebar-session-organization.spec.tspackages/app/e2e/sidebar/sidebar-slice-09.spec.tspackages/app/src/i18n/en.tspackages/app/src/i18n/zh.tspackages/app/src/pages/layout.tsxpackages/app/src/pages/layout/pawwork-sidebar.tsxpackages/app/src/pages/layout/session-menu-actions.test.tspackages/app/src/pages/layout/session-menu-actions.tspackages/app/src/pages/layout/sidebar-items.tsxpackages/app/src/pages/layout/sidebar-status-kind.test.tspackages/app/src/pages/layout/sidebar-status-kind.tspackages/app/src/pages/layout/sidebar.csspackages/ui/src/components/button.csspackages/ui/src/components/dropdown-menu.csspackages/ui/src/components/icon-button.csspackages/ui/src/components/icon.tsxpackages/ui/src/components/line-comment-styles.tspackages/ui/src/components/picker.csspackages/ui/src/components/spinner.csspackages/ui/src/components/spinner.tsxpackages/ui/src/components/text-field.csspackages/ui/test/icon-button-states.test.ts
Pinned idle sessions now show a pin glyph in the right slot instead of falling through to the timestamp. Priority becomes asking → busy → error → pin → time, matching the L35 mental model: the three live signals still preempt pin, but pin is meaningful enough to displace the passive time. - sidebar-status-kind.ts: add "pin" kind + pinned input; tests cover every pairwise priority and the pinned-only case. - sidebar-items.tsx: SessionItem accepts isPinned, statusKind feeds pinned only when no live signal is active, statusContent renders Icon name="pin" with text-icon-weak (passive-tier color, lighter than the live brand/error tints). - pawwork-sidebar.tsx: pass isPinned per row by checking pinnedIDs().includes(session.id).
The "Pinned" section header at the top of the sidebar already signals which sessions are pinned — adding a per-row pin icon repeats the same information. The header is present in both sort modes (time and project), so location alone is sufficient. Restores the original 4-state right-slot priority: asking → busy → error → time.
…nu.css DropdownMenu (three-dots) and ContextMenu (right-click) were styled by two separate CSS files that had drifted: different row height, hover overlay, item radius, font size, content radius, and shadow. The two menus showed the same actions but didn't look the same. Collapse both stylesheets into a single menu.css using combined selectors that cover both Kobalte primitives. Trigger semantics still differ at the component layer; only the visual surface is unified. Future tweaks land once and apply everywhere — no sync drift.
…debar Clicking a session row or "New Session" while the settings overlay was open did nothing visible. The path-watcher effect intended to close settings on URL change captured pathname at unreliable moments and did not fire consistently, so the overlay stayed on top of the navigation target. Make the close explicit: shell-navigation now closes the settings surface as the first step of openSession and openNewSession (including the project-chooser fallback). Drop the path-watcher effect — direct caller-side close is the canonical path.
"PawWork Desktop" was wider than the settings sidebar footer slot and read as redundant — every settings page is the desktop one. Drop the suffix in EN; ZH stays "爪印".
The palette was tight: search row was a thin 40px placeholder with no icon, items used picker.css default 0/8 padding (off-spec, L32 wants 0/12), and the modal grew/shrank with result count which made every keystroke jump the page. Changes - Search header is now a 52px ghost strip with leading magnifying glass and 16px horizontal padding; input renders type-body without the 20px clamp that made the box read as cramped. - Body locks to 480px (or 100dvh - 32px on small windows) so result count changes scroll inside the modal instead of resizing it. - Items align to L32: height 32, padding 0/12, radius-sm; selected row uses surface-interactive-base + brand-primary check icon. - Section headers get type-h3 family (uppercase, 0.5 tracking) per L32; empty state grows to 48/32 padding. - Drop hideIcon from the file/command picker so the palette shows the search icon to match other List consumers' search affordance.
The model picker, variant Select, and workspace chip showed visibly different font weights in the composer footer. Two were rendered via the Button component (which sets font-weight: medium by default), and the existing override class "text-13-regular!" did not apply because Tailwind v4 only processes the "!" important suffix on its own utilities — not on custom CSS classes — so the bang was a silent no-op. Make all three triggers explicitly font-normal (Tailwind built-in), and clean up the bogus "!" suffixes (text-13-regular!, justify-start!) so the class lists describe what actually applies.
When the sidebar is sorted by project, each project group can now be folded so the user can hide branches they're not actively working on without losing them. - Project header is a clickable button (whole row hot, not just the chev) with a leading folder icon to distinguish project groups from session rows, and a trailing chev that appears on hover and rotates -90° when collapsed. - Default expanded; folded state persists per-project label in the page store with a sanitizing migrate so corrupt entries can't bleed into runtime. - Toggle uses reconcile so removing a key actually drops it (default setStore on objects merges and would never clear the field). - Animate height via grid-template-rows 0fr↔1fr, 200ms exponential ease-out, motion-reduce friendly. Items stay mounted so scroll and focus survive the toggle; inert removes them from the tab order while collapsed. - Tighten inter-section margin from mt-4 to mt-0.5 so collapsed groups list at the same rhythm as session rows. The 16px section break in L35 is for top-level segments (Pinned / All / Footer), not project sub-groups.
The two new sidebar behaviors in slice-09 had no real-user-path coverage — only the shell-navigation unit test verified the close-on- navigate call order, and project collapse had no test at all. Per AGENTS.md, sidebar / dialog / menu interactions need E2E. - project-collapse.spec.ts: clicks the project group toggle, asserts aria-expanded / data-collapsed flips and the wrapper's bounding box height collapses to 0 (grid-template-rows 0fr clip). Second case reloads and asserts the persisted collapsed state survives. - settings-close-on-nav.spec.ts: opens the settings overlay, clicks a sidebar session, asserts the overlay hides and the URL navigates to the clicked session. Tag the collapse content wrapper with data-component / data-collapsed so the test can target the visible-hide signal — Playwright's toBeHidden treats overflow:hidden + 0fr clipping as visible since the clipped element keeps its layout height.
Sort trigger stayed icon-only after manual UX review (a text+chev trigger does not fit the L35 row beside the All header); rename the test to describe the actual behavior. Tighten the three-segment shape assertions so failures point to a specific bounding box rather than a boolean roll-up.
The slice 09 change to --radius-md and --row-active-overlay was meant for the sidebar three-dot trigger that sits on top of the row's own hover layer; applying it to the global IconButton selector pulled the heavier overlay and wider radius into titlebar, prompt toolbar, and every other ghost icon button. Revert the base to --radius-sm and --hover-overlay (the documented IconButton contract), and add a sidebar-row-scoped override so the slice 09 visual still ships where intended. Tighten the contract test to assert the base radius and to match the hover token inside the &:hover rule rather than anywhere in the file.
The shared Spinner sat in the UI package with a hardcoded aria-label="Loading", so screen readers always announced English even when the rest of the sidebar was localized. Add an optional aria-label prop (default unchanged) and pass the translated string from the sidebar busy state. Also gate the infinite rotation behind prefers-reduced-motion so users with reduced-motion settings don't see a continuously spinning indicator.
Slice 09 replaced the unreliable path-watcher effect with an explicit closeSettingsSurface call inside createShellNavigation, which covers sidebar-driven session opens. Notification clicks (and any other caller of @/utils/notification-click) route through useNavigate directly and were leaving the settings overlay on top of the new route. Wrap the navigate function injected into setNavigate so it closes the overlay before routing, restoring the same guarantee for non-shell entry points without bringing back the path-watcher effect.
The full 4-state case already locks down the icon sequence; mirror it in the exportAvailable: false path so a regression that swaps pin / rename / delete icons cannot slip through.
## Summary Slice 09 of the #440 visual rewrite. Closes #303. - Row outer shell rebuilt to L35: 32px tall, 10px horizontal padding, radius-sm, flex-centered (no leading-[1.4]). Active title now picks up `font-medium`. - Right-slot status reduced to the 4-state model: `asking` (comment icon, brand orange) → `busy` (spinner) → `error` (circle-x, red) → `time` (sans 12, fg-weaker). Old 6px colored dots and the pinned-icon state are gone — pin status is signaled by the row's presence in the Pinned section. - Action overlay three-dot iconbtn scoped to `--radius-md` and `--row-active-overlay` (0.06) hover so it reads as its own target on top of the 0.04 row hover. The IconButton base contract stays at `--radius-sm` + `--hover-overlay`; the slice 09 override is scoped to `[data-component="pawwork-session-row"] [data-action="session-row-menu"]` only. Status fades to opacity 0 (no `display:none`). - 4-item menu: Pin / Rename / Export… / (sep) / Delete, each with a leading icon. Keyboard shortcut affordances (the `↵` / `⌘` / `⌫` hint glyphs and a real `<kbd>` element) are deferred to slice 14 (L46) — slice 09 ships the icon-leading layout only. - Sort: replaced the icon-only filter toggle with a Kobalte DropdownMenu trigger (still icon-only `IconButton icon="sort"` to fit the 32px row beside the All label) offering 按时间 / 按项目 (By time / By project), with active-mode check icon and `data-mode` for testability. - Layout shape: existing segments now carry `pawwork-side-top` / `pawwork-side-scroll` / `pawwork-side-foot` markers so L37's shell shape is testable. The traffic / collapse-control segment (`pawwork-side-traffic`) is intentionally deferred to slice 17 — the OS chrome already renders the macOS traffic-lights today, and adding a 32px placeholder now would double the empty space at the top. Slice 17 hides the OS chrome and moves the controls into the sidebar; that's when the placeholder lands. - Footer: settings button keeps its `TooltipKeybind` wrapper for the keybind affordance; the visible row matches the L35 nav cluster (32 high, 10 horizontal padding) alongside the other two sidebar nav buttons. - Removed dead `ProjectIcon` export from `sidebar-items.tsx` (no callers). - Sidebar geometry uses literal pixels (`h-[32px]`, `size-[20px]`, `size-[16px]`) because PawWork's 13px root font size scales Tailwind rem-based sizing — `h-8` would render at 26px, not 32. - Project group collapse (#50): project headers in the project sort mode are now toggle buttons with a folder icon and a hover-revealed chevron. Collapsed state animates via grid-template-rows 0fr↔1fr (motion-reduce friendly), is keyed by project label, and persists across reloads via `pawworkProjectCollapsed` on `layout-page-store`. Items stay mounted while collapsed (focus / scroll preserved) and `inert` removes them from the tab order. - Settings overlay close-on-nav: clicking a sidebar session (or "new session") while the settings overlay is open closes the overlay before navigating, wired through `closeSettingsSurface` in `createShellNavigation`. The global `setNavigate` hook is also wrapped so notification clicks and other non-shell deep-link routes close the overlay before routing. - Spinner: the shared `Spinner` now accepts an optional `aria-label` prop (default `"Loading"`) so localized callers can pass `language.t("common.loading")`; the keyframe is gated behind `prefers-reduced-motion: reduce`. - Command palette redesign (search header geometry, fixed body height, item rules) — full content / feature redesign tracked as follow-up #501. - Project rename / delete actions tracked as follow-up #502 (out of slice 09 scope). ## Boundary check (no out-of-scope paths) Touched only: - `packages/app/src/pages/layout/{sidebar-items.tsx, pawwork-sidebar.tsx, sidebar.css, layout.tsx, layout-page-store.ts, shell-navigation.ts, session-menu-actions.{ts,test.ts}, sidebar-status-kind.{ts,test.ts}}` - `packages/app/src/i18n/{zh.ts, en.ts}` (sort + settings keys; `app.name.desktop` shortened to `PawWork`) - `packages/app/src/components/{prompt-input.tsx, prompt-input/workspace-chip.tsx, dialog-select-file.tsx}` (font-weight unification + magnifying-glass restoration on the file dialog) - `packages/ui/src/components/{command-palette.css, menu.css, icon-button.css, spinner.{tsx,css}}` (palette geometry, unified context+dropdown menu styling, row-overlay scoping, shared spinner) - `packages/app/e2e/sidebar/{sidebar-slice-09.spec.ts (new), sidebar-project-collapse.spec.ts (new), sidebar-session-organization.spec.ts (selector), sidebar-leading-slot.spec.ts (drop obsolete pin-button assertion)}` - `packages/app/e2e/settings/settings-close-on-nav.spec.ts (new)` No changes to `sidebar-workspace.tsx`, titlebar, composer, message-flow, settings page content, or `packages/opencode`. ## Departures - **Sub-session indentation** kept at `padding-left = 10 + level * 16`. The L35 anatomy locks the flat row; nested sessions need visual hierarchy. Annotated inline in `sidebar-items.tsx`. - **Right-slot `error` state** is wired to the existing `notification.session.unseenHasError` signal (unread tool-output errors). Without a session-level error field in the data layer today, unseen-error is the available proxy. - **Row left padding base 8 → 10** to match L35 row padding 0 10 (was `8 + level*16`, now `10 + level*16`). - **`NewSessionItem` leading icon goes 14 → 16** (the standard chrome size after slice 08). The previous 14px override in `sidebar.css` was a deliberate departure from slice 08 with no L35/L37 backing — dropped. - **Sort trigger stays icon-only.** L35 row geometry only fits a 20px icon button beside the "All" label; a text + chevron trigger would force the All header to two lines or push the action out of the row. The DropdownMenu still exposes the two options with check + `data-mode`, and the `pawwork-sort-trigger` selector is stable for E2E. - **`pawwork-side-traffic` placeholder deferred to slice 17.** The OS already paints macOS traffic-lights on the window chrome; reserving a 32px band on top of that would render 64px of empty space at the sidebar head. Slice 17 hides the OS chrome and moves the cluster into the sidebar — that's when the placeholder lands without doubling the void. The deferral is annotated inline in `pawwork-sidebar.tsx`. - **Menu shortcut hints deferred to slice 14.** An earlier draft of this slice rendered placeholder span hints next to each menu label; that was rolled back in favor of leading icons (commit `bf2f1d6`). Real `<kbd>` and the canonical hint affordance ship with slice 14 (L46). - **Settings footer keeps `TooltipKeybind`.** The inline ⌘ glyph belongs with the broader keybind hint affordance in slice 14; until then, the tooltip surface remains the source of truth for the shortcut. ## Test plan - [x] `bun turbo typecheck --filter=@opencode-ai/app --filter=@opencode-ai/ui` (5/5 packages) - [x] `bun test --preload ./happydom.ts src/pages/layout` (101 pass, 0 fail) - [x] `bun test:e2e -- sidebar/sidebar-slice-09.spec.ts` (3/3 pass) - [x] `bun test:e2e -- sidebar/sidebar-project-collapse.spec.ts settings/settings-close-on-nav.spec.ts` (3/3 pass) - [x] `bun test:e2e -- sidebar/sidebar-session-organization.spec.ts sidebar/sidebar-leading-slot.spec.ts` (3/3 pass) - [x] Manual `bun run dev:desktop` Electron walkthrough — verified sidebar rows (hover/active/focus), three-dot menu and right-click menu parity, Pin/Rename/Export/Delete, sort popover toggle, status states (asking/busy/error/time), project collapse persistence across reload, settings close-on-nav for sidebar sessions, command palette geometry, light + dark themes. ## Crosscheck Plan was reviewed by Claude (opus) + Codex prior to implementation. P0 candidates were investigated and dropped (`comment` icon exists; `STANDARDS.md` is a real local-only spec source). All P1/P2 findings were folded back into the plan and applied in commits. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added sidebar sort functionality with time and project options * Added project group collapse with persisted state across reloads * Added keyboard shortcut indicators in UI elements * **UI/UX Improvements** * Standardized component heights across the interface (28px → 32px) * Redesigned spinner animation using CSS-based approach * Enhanced dropdown menu styling and hover behavior (unified with right-click menu) * Added icons to session menu actions * Settings overlay now closes when navigating away via the sidebar * **Localization** * Added English translations for release notes UI and sorting controls * Added Chinese translations for sidebar sorting features <!-- end of auto-generated comment: release notes by coderabbit.ai -->
Summary
Slice 09 of the #440 visual rewrite. Closes #303.
font-medium.asking(comment icon, brand orange) →busy(spinner) →error(circle-x, red) →time(sans 12, fg-weaker). Old 6px colored dots and the pinned-icon state are gone — pin status is signaled by the row's presence in the Pinned section.--radius-mdand--row-active-overlay(0.06) hover so it reads as its own target on top of the 0.04 row hover. The IconButton base contract stays at--radius-sm+--hover-overlay; the slice 09 override is scoped to[data-component="pawwork-session-row"] [data-action="session-row-menu"]only. Status fades to opacity 0 (nodisplay:none).↵/⌘/⌫hint glyphs and a real<kbd>element) are deferred to slice 14 (L46) — slice 09 ships the icon-leading layout only.IconButton icon="sort"to fit the 32px row beside the All label) offering 按时间 / 按项目 (By time / By project), with active-mode check icon anddata-modefor testability.pawwork-side-top/pawwork-side-scroll/pawwork-side-footmarkers so L37's shell shape is testable. The traffic / collapse-control segment (pawwork-side-traffic) is intentionally deferred to slice 17 — the OS chrome already renders the macOS traffic-lights today, and adding a 32px placeholder now would double the empty space at the top. Slice 17 hides the OS chrome and moves the controls into the sidebar; that's when the placeholder lands.TooltipKeybindwrapper for the keybind affordance; the visible row matches the L35 nav cluster (32 high, 10 horizontal padding) alongside the other two sidebar nav buttons.ProjectIconexport fromsidebar-items.tsx(no callers).h-[32px],size-[20px],size-[16px]) because PawWork's 13px root font size scales Tailwind rem-based sizing —h-8would render at 26px, not 32.pawworkProjectCollapsedonlayout-page-store. Items stay mounted while collapsed (focus / scroll preserved) andinertremoves them from the tab order.closeSettingsSurfaceincreateShellNavigation. The globalsetNavigatehook is also wrapped so notification clicks and other non-shell deep-link routes close the overlay before routing.Spinnernow accepts an optionalaria-labelprop (default"Loading") so localized callers can passlanguage.t("common.loading"); the keyframe is gated behindprefers-reduced-motion: reduce.Boundary check (no out-of-scope paths)
Touched only:
packages/app/src/pages/layout/{sidebar-items.tsx, pawwork-sidebar.tsx, sidebar.css, layout.tsx, layout-page-store.ts, shell-navigation.ts, session-menu-actions.{ts,test.ts}, sidebar-status-kind.{ts,test.ts}}packages/app/src/i18n/{zh.ts, en.ts}(sort + settings keys;app.name.desktopshortened toPawWork)packages/app/src/components/{prompt-input.tsx, prompt-input/workspace-chip.tsx, dialog-select-file.tsx}(font-weight unification + magnifying-glass restoration on the file dialog)packages/ui/src/components/{command-palette.css, menu.css, icon-button.css, spinner.{tsx,css}}(palette geometry, unified context+dropdown menu styling, row-overlay scoping, shared spinner)packages/app/e2e/sidebar/{sidebar-slice-09.spec.ts (new), sidebar-project-collapse.spec.ts (new), sidebar-session-organization.spec.ts (selector), sidebar-leading-slot.spec.ts (drop obsolete pin-button assertion)}packages/app/e2e/settings/settings-close-on-nav.spec.ts (new)No changes to
sidebar-workspace.tsx, titlebar, composer, message-flow, settings page content, orpackages/opencode.Departures
padding-left = 10 + level * 16. The L35 anatomy locks the flat row; nested sessions need visual hierarchy. Annotated inline insidebar-items.tsx.errorstate is wired to the existingnotification.session.unseenHasErrorsignal (unread tool-output errors). Without a session-level error field in the data layer today, unseen-error is the available proxy.8 + level*16, now10 + level*16).NewSessionItemleading icon goes 14 → 16 (the standard chrome size after slice 08). The previous 14px override insidebar.csswas a deliberate departure from slice 08 with no L35/L37 backing — dropped.data-mode, and thepawwork-sort-triggerselector is stable for E2E.pawwork-side-trafficplaceholder deferred to slice 17. The OS already paints macOS traffic-lights on the window chrome; reserving a 32px band on top of that would render 64px of empty space at the sidebar head. Slice 17 hides the OS chrome and moves the cluster into the sidebar — that's when the placeholder lands without doubling the void. The deferral is annotated inline inpawwork-sidebar.tsx.bf2f1d6). Real<kbd>and the canonical hint affordance ship with slice 14 (L46).TooltipKeybind. The inline ⌘ glyph belongs with the broader keybind hint affordance in slice 14; until then, the tooltip surface remains the source of truth for the shortcut.Test plan
bun turbo typecheck --filter=@opencode-ai/app --filter=@opencode-ai/ui(5/5 packages)bun test --preload ./happydom.ts src/pages/layout(101 pass, 0 fail)bun test:e2e -- sidebar/sidebar-slice-09.spec.ts(3/3 pass)bun test:e2e -- sidebar/sidebar-project-collapse.spec.ts settings/settings-close-on-nav.spec.ts(3/3 pass)bun test:e2e -- sidebar/sidebar-session-organization.spec.ts sidebar/sidebar-leading-slot.spec.ts(3/3 pass)bun run dev:desktopElectron walkthrough — verified sidebar rows (hover/active/focus), three-dot menu and right-click menu parity, Pin/Rename/Export/Delete, sort popover toggle, status states (asking/busy/error/time), project collapse persistence across reload, settings close-on-nav for sidebar sessions, command palette geometry, light + dark themes.Crosscheck
Plan was reviewed by Claude (opus) + Codex prior to implementation. P0 candidates were investigated and dropped (
commenticon exists;STANDARDS.mdis a real local-only spec source). All P1/P2 findings were folded back into the plan and applied in commits.Summary by CodeRabbit
New Features
UI/UX Improvements
Localization