Skip to content

feat(ui,app): composer rewrite (slice 10, issue #440)#508

Merged
Astro-Han merged 39 commits into
devfrom
claude/slice-10-composer
May 9, 2026
Merged

feat(ui,app): composer rewrite (slice 10, issue #440)#508
Astro-Han merged 39 commits into
devfrom
claude/slice-10-composer

Conversation

@Astro-Han

@Astro-Han Astro-Han commented May 9, 2026

Copy link
Copy Markdown
Owner

Summary

Slice 10 of #440 rewrites the composer dock surface for the new design system: control heights unified to the 30 dense tier, picker-contract alignment for model/variant/thinking-level popovers, the three dock widgets (Todo / Followup / Revert) sharing one geometry + collapse contract, and Followup behavior shifted from steer-on-submit to queue-by-default.

Highlights

  • Composer chrome aligned with DESIGN.md L23/L33/L34 — heights, padding, hierarchy
  • Model + thinking-level pickers adopt the picker contract (tight padding, narrowed width, sticky labels removed)
  • Send button theme-locked, stop glyph enlarged from 25% to 40% of the icon viewBox
  • Sidebar trailing actions dropped to dense 26 (nesting rule)
  • Three dock widgets (Todo / Followup / Revert) now share DockWidgetHeader + useDockCollapse for header geometry and collapse animation; Todo keeps a private dockProgress fade layer on top
  • Tooltip text uses inherited foreground, not theme-locked text-fg-on-brand
  • Followup defaults to queue mode (submit-during-busy enqueues); 立即发送 button on each queued item explicitly steers mid-stream
  • Header pinned at h-[36px] absolute pixels: this app sets root font-size to 13px via html { font: var(--type-body) } in theme.css, which makes Tailwind h-9 resolve to 2.25rem × 13 = 29.25px — short of the 30px IconButton, causing the chev to overflow and clip against the segment's overflow-y: hidden. DESIGN.md L305 commits to absolute 36px, so absolute-pixel utilities are the honest mapping.

Test plan

  • dev:desktop end-to-end: submit / abort / queue-during-busy / mid-stream steer
  • Three dock widgets fold/unfold animation parity
  • Chev IconButton 3+3 symmetric in all three docks' collapsed states
  • Cmd+', cmd+K, model picker, variant select, thinking-level popover
  • Cross-workspace draft persistence ([Feature] Keep unsent text visible when switching directories in New Session #185)
  • Dark theme parity for everything above
  • Drag-and-drop, attachments, history navigation regression check

Followup debt

DESIGN.md updates accumulated this slice will land in a separate docs PR after manual testing closes. Notable systemic finding: this app's root font-size is 13px, so Tailwind unit-spacing is 3.25px per unit, not the 4px assumed by DESIGN.md / STANDARDS. DESIGN.md absolute-pixel contracts (36px, 30px, 8px, etc.) need either a codebase-wide root font-size fix or a sweep migrating to [Npx] arbitrary-value utilities.

Summary by CodeRabbit

  • New Features

    • Thinking-level (model variant) selection integrated into the model picker popover
    • Draft carry-over preserves prompt text when switching directories
  • Bug Fixes

    • Model selection now opens as a popover/listbox instead of a dialog
    • Removed the unpaid model selection dialog
  • Style

    • Standardized button/icon sizing to 30px and refined spacing
    • Updated desktop dark theme and various dock/composer visuals
  • Tests

    • Added tests covering draft carry-over behavior

Astro-Han added 30 commits May 8, 2026 14:34
…ith ui_kit

- composer DockCard: dark mode uses surface-raised + drop shadow to match ui_kit floating feel; light mode keeps ring + shadow-raised
- shell dark tokens: switch from cold gray (#212121/#181818) to warm coffee (#221f1c/#1a1714) so halo blends with composer surface
- titlebar macOS: right half transparent so page halo shows through both titlebar and main continuously
- main content: drop opaque bg-bg-base layers so page-level halo reaches composer surface
- composer width: home and session both 720px / 2xl:920px (was home 720, session 720→920)
- delete cold-gray-era 1px border-t divider in layout.tsx
Move PromptStore/HistoryStore types to prompt-input/store-types.ts and
DOM <-> Parts bidirectional serialization (createPill, isNormalizedEditor,
renderPartsToEditor, parseEditorToParts) to prompt-input/editor-serialize.ts.

Pure functions, no Solid reactivity. Pairs with editor-dom (cursor primitives).
First step of splitting prompt-input.tsx (1602 lines) into business-domain modules.
…ratives.ts

Move 10 imperative side-effect helpers (scrollCursorIntoView, queueScroll,
clearEditor, setEditorText, focusEditorEnd, currentCursor, restoreFocus,
renderEditorWithCursor, getCaretState, escBlur) into a single factory.

All operate via editorRef/scrollRef getters; no Solid reactivity introduced.
PromptInput now destructures the helpers it needs from createEditorImperatives.
Move activeFileTab, recent, and openComment (with its commentInReview helper)
into prompt-input/comment-routing.ts. The factory consumes useSessionLayout/
useFile/useComments/useSync internally, exposing only what PromptInput needs.

This logic was inside the composer historically but routes clicks into the
review side panel — it's not composer-internal.
Move two persisted history stores, historyComments, applyHistoryComments,
applyHistoryPrompt, addToHistory, and navigateHistory into
prompt-input/history-navigation.ts.

History is a self-contained subsystem: persisted entries (normal + shell),
apply logic with comment synchronization, and navigation. setCursorPosition
imports directly from editor-dom (no detour through editor-imperatives).

Adopt PromptStore type from store-types in the main createStore call.
…nput.ts

Move composing/IME signal, handleBlur, composition handlers, reconcile,
the prompt -> DOM reconcile effect, the carry-over effect, handleInput,
and addPart into createEditorInput factory.

For now atOnInput/slashOnInput/closePopover/resetHistoryNavigation are
passed in as direct closures from PromptInput. The next commit extracts
popover-controllers and converts these to a popovers() ref forward to
break the addPart <-> popover-handlers cycle.

Pure functions (parseEditorToParts, isNormalizedEditor, createPill,
createTextFragment, setRangeEdge) are imported directly from editor-
serialize/editor-dom rather than threaded through deps.
Move @ file popover, / command popover (with their useFilteredList instances),
slash auto-scroll effect, the promptProbe testing hook (with its
promptEnabled() guard preserved), and selectPopoverActive into
createPopoverControllers.

Resolve the addPart <-> popover-handlers cycle with a mutable popoversRef:
PromptInput creates editorInput first (passing popoversAccess that throws if
not yet initialized), then creates popovers with editorInput.addPart, then
assigns popoversRef. editorInput's handleInput now calls deps.popovers()
.atOnInput / .slashOnInput.

The throw is intentional — silent fallback would drop @/slash input events
if the assembly order ever regressed. slashPopoverRef typed nullable
(assigned by PromptPopover on mount).
Move handleKeyDown (the 169-line key dispatch switch) into
createPromptKeydownHandler factory. The handler routes events to popover,
history, shell-mode, file-pick, and submit/abort subsystems based on the
key combination.

PromptInput passes 17 deps to the factory, all of which were already
local closures in the handler — preserves behavior verbatim.
Move renderModelControl and renderVariantControl into PromptModelControl
and PromptVariantControl components in prompt-input/model-controls.tsx.
The variants memo moves into PromptVariantControl since it's only consumed
there.

variantOpen state stays in PromptInput (paired with the close-on-disable
effect) and is passed as open/onOpenChange props — splitting that effect
across files would double the disable subscription. PromptVariantControl
exposes onOpenChange (no separate onClose prop, since the underlying
Select doesn't have one); PromptInput's onOpenChange handler calls
restoreFocus when the popover closes.
- prompt-input.tsx: drop ContentPart, useLayout, Popover imports and the
  layout/tabs/view destructured locals — all moved into split modules
- prompt-input.tsx: drop historyComments from createHistoryNavigation
  destructure (only addToHistory and navigateHistory are consumed)
- comment-routing.ts: stop returning activeFileTab from createCommentRouting
  (only used internally by the recent memo, was never consumed externally
  in the original file either)
#440)

Home composer wrapper used max-w-[720px] 2xl:max-w-[920px] inside an outer
max-w-200 (= 800px) cap. The cap shadowed the 2xl branch — at 2xl the popover
was held to 800px even though the inner intent was 920px. Drop the outer cap
and tighten the inner wrapper to max-w-[640px] (matches the skill chips
max-width directly below). Home composer now sits in a 640px column that
visually rhymes with the chip group, while session-internal composer keeps
the existing 720 / 2xl 920 responsive width — narrower home is the design
intent (hero stage vs working strip).
Trim the picker popover to the design intent for slice 10:
- Width 340 → 280 (covers ~95% of popular provider model names without
  truncation; verified empirically across 256 models, see brainstorm width
  calculator).
- Drop the autofocus search bar inside the menu — the ⌘K full-screen picker
  already covers high-frequency search; the popover stays for low-frequency
  quick-switch within ≤ 5 visible models per group.
- Drop the "+ add provider" / sliders icon-button action and the unpaid
  "add more provider" footer CTA. Provider management is a low-frequency
  one-shot action and belongs in settings (⌘,), not in the high-frequency
  quick-switch surface.
- Add provider icon to each model row (left of name) — design preview shows
  it, ModelTooltip alone is not enough at-a-glance.
- Remove now-unused imports (useDialog, useProviders, IconButton, Button,
  Show) and the dialog-bound dismiss kinds ("manage" / "provider"); keep
  popularProviders for sortGroupsBy and Tooltip for the per-row
  ModelTooltip wrapper.

Group/row spacing (group-gap 12, row-gap 2, header 12px / 400) is unchanged
because the underlying List component already aligns with dev's contract;
no overrides needed.
…#440)

Append a thinking-level section to the bottom of the model picker popover,
separated from provider groups by a 1px border-weaker divider + pt-3 to
visually mark the conceptual jump (gap = same layer; line = cross layer).

The section is data-driven against model.variant: it renders only when the
current model exposes at least one variant. The trigger row shows the
section label "思考档位" / "Thinking" and the current variant translated
via translateVariant (default → "默认" / "Default"). Clicking the trigger
opens a nested Kobalte popover to the right (placement="right-start"),
listing ["default", ...variant.list()] as a single-select. Selecting
"default" calls model.variant.set(undefined); selecting any other value
calls .set(rawName), so SDK-side reasoningEffort plumbing is unchanged.

Why nested Kobalte popover (vs inline segmented or absolute div):
- Inline segmented bunches a low-frequency adjustment ("调档") into the
  high-frequency main path ("换模型"); ⌘K already covers high-frequency
  search, so the picker stays for ≤5 quick-switch — the bottom section
  shouldn't compete with model rows for visual budget.
- The flyout placement makes it easy to keep the trigger row visible
  alongside the option being selected, and Kobalte handles the focus /
  outside-click contract correctly when nested inside the outer popover.
- Manual absolute-positioned div would be clipped by the outer
  Kobalte.Content overflow-hidden; nested popover routes through portal.

i18n: add "dialog.model.variant" key in en.ts ("Thinking") and zh.ts
("思考档位"). Variant labels reuse the existing variant-label.ts mapping
(none/minimal/low/medium/high/xhigh/max + default), no new translations
needed.

The ⌘. variant cycle command keybind is unchanged — keyboard users keep
quick cycling without opening the menu.
…(slice 10, issue #440)

The variant (thinking level) selection moved into the model picker popover
in the previous commit. This commit removes the now-redundant standalone
PromptVariantControl button next to the model trigger, and folds the
current variant into the model trigger as an inline indicator
("· {variant i18n}"), so the bottom bar shows one combined picker instead
of two adjacent dropdowns.

model-controls.tsx
- Delete PromptVariantControl entirely (Select-based standalone control)
- PromptModelControl trigger: render "· {translateVariant(current)}" after
  the model name when model.variant.current() is truthy. Default (undefined)
  hides the inline tag — matches the picker popover trigger contract:
  no tag when off, raw-translated label when on.
- Drop unused createMemo, Select imports
- Update file header comment

prompt-input.tsx
- Remove PromptVariantControl from the named import
- Delete the [variantOpen, setVariantOpen] signal — Select component owned
  its own open state from inside model-controls; the parent signal was the
  controlled-mode escape hatch that no longer has a control to drive
- The actionReady close-on-disable createEffect keeps closePopover() and
  setStore("draggingType", null), drops setVariantOpen(false)
- Delete the <PromptVariantControl> JSX block

The ⌘. variant cycle command (model.variant.cycle keybind) is unchanged
in command.tsx; users keep keyboard cycling without a button to click.
Render permission inside the composer DockCard instead of a separate dock
above it, so the permission and input states visually share the same shell.
SessionPermissionDock becomes SessionPermissionContent (no DockPrompt
wrapper); the existing data-component="dock-prompt"[data-kind=permission]
attribute is preserved so the slot CSS still applies.

Adds min-h-[140px] to the composer DockCard for the empty-input minimum.
…#440)

The composer drifted from STANDARDS.md and picker.css contract on a few
details. Rather than tuning each magic number, plug the composer into the
existing contracts so the values come from one place.

- attach: Button + size-7 override → IconButton (L23: ghost icon button is
  one fixed 24×24 token; using IconButton retires five className overrides
  and the unused Button import)
- model picker content: add data-picker-content="" and drop inline radius +
  shadow + bg (L34 / picker.css: surface-base + radius-md + ring-base +
  shadow-floating + padding 4 come from the contract)
- model picker trigger: drop px-1.5 (let Button default 0/8 padding stand;
  the previous 6px override pinched the icon + name + chev row)
- provider icon in trigger: drop opacity-40 + group-hover:opacity-100 +
  will-change/translateZ hacks (preview spec is rest fg-base, not faded)
- composer-bar gap: gap-3 → gap-2 (L33 explicitly locks composer-bar gap
  to 8)
- composer-bar left gap: gap-2 → gap-1 (preview composer-left gap is 4)
Collapse the prior 24/28/32 mixed control system into one tier at 30px
with a 26px dense sub-layer. Industry alignment per shadcn/Material 3/
Carbon/Radix/Primer: icon-only buttons match text-button height as a
square version (30x30), icon SVG remains independent on 16/20/24.

- L23 icon-button: 30x30 square (titlebar-icon 32x30 non-square retained)
- L25 button / L26 input / L34 picker / L35 sidebar row: 30
- Send button: 30 round
- L42 sub-tab pill, L34 chip, filetree row, collapsible arrow,
  diff-hunk line-number icon, file-search inner buttons,
  titlebar-internal pills (worktree badge, project dir): 26
- session-turn-changes-action: rewritten with flex centering, drops the
  line-height: 24 vertical-center hack
- DockCard: drop min-h-[140px] so the empty composer card no longer
  stretches on the home screen
- IconButton call sites: drop redundant size-6 / w-8 h-7 overrides so
  the default 30x30 takes effect
session-row 三点 / sort trigger / workspace 三点 / workspace 新建会话 四个
trailing icon button 从默认 30 降到密集层 26,贴齐 DESIGN.md 新增的
Nesting rule:30 高行内的 trailing 操作降到 26,宿主行保持 30,给
按钮留可见呼吸,避免 sort 跟首条会话粘连、菜单按钮跟行边缘贴死。
… width

- picker.css: clear nested List 0/12 wrapper inside picker context
  (DESIGN.md L30 popover spec is container 4 + item 0/8; List was adding
  a third 12px layer making the highlight float 24px from the edge)
- picker.css: drop sticky group labels in picker (groups are short, sticky
  caused mouse-wheel-scrolled items to be overlapped by the label)
- picker.css: add 4px gap between header and first item now that sticky
  no longer floats the label
- model-picker.tsx: 280 -> 240 width, freed by the padding fix
- model-picker.tsx: thinking-level row stays visible even when the model
  has no variants (trigger disabled, label "default") so the section
  doesn't disappear depending on model
The send/stop button uses theme-aware tokens (--fg-strong / --bg-base)
which mirror across light and dark, breaking the muscle-memory contract
for the primary action button. Lock the colors so light and dark look
visually identical:

- idle/ready: brand orange + white (was: brand orange + bg-base, which
  in dark theme rendered an unreadable dark-page-color icon on orange)
- stopping:   ink #1A1613 + white (was: --fg-strong + --bg-base, which
  in dark theme inverted to bone-white bg + dark icon, mirroring light)

Also enlarge stop-square SVG from 8x8 (21% of button) to 15x15 (40% of
button) to match Claude.ai / ChatGPT proportions; the prior glyph was
hard to read against a 30px circular button.

Theme-locked raw color values are an intentional call-site exception
for affordance buttons and will be added to DESIGN.md deviations.
Manual buttons in the thinking-level secondary popover painted the
browser default :focus ring on mouse open because they bypassed the
picker contract that scopes the ring to :focus-visible. Tagging Content
with data-picker-content and items with data-picker-item / data-selected
routes them through picker.css so mouse-click no longer shows the ring,
keyboard tab still does, and selected/hover states match the rest of
the picker family.
Collapsed widget height is 36 per DESIGN.md (Composer · dock · model menu):
30 chev IconButton centered in a 36 row gives 3px breathing top/bottom and
distinguishes a widget header from a plain control row. Pre-L34 the dock was
a standalone rounded card and 78 over-padded it.

Three coupled fixes:
- Min height 78 -> 36 (two callsites: full() memo and DockSegment max-height
  floor) so the collapsed header matches the spec.
- Progress span and TextReveal: leading-none + items-center. text-13-regular
  inherits line-height-large (150% = 19.5px line-box); CJK glyphs sit in the
  upper portion of the em-box, so flex items-center on the line-box centered
  the box but not the glyphs. Collapsing the line-box to the em-box lets the
  glyphs land on the optical centerline.
- TodoList bottom padding pb-11 -> pb-3. The 44px tail was scaffolding for
  the scroll-stuck gradient overlay (now removed); 12px is enough breathing
  for the last item without leaving a visible empty band on full lists.
text-fg-on-brand is locked to #ffffff for use on the brand-orange surface
and does not invert in dark mode. The context-usage tooltip uses the
default tooltip surface (background: var(--fg-strong); color:
var(--bg-cream)), both tokens auto-invert by scheme. Forcing white text on
top of that produced white-on-light in dark mode (tooltip bg becomes light
cream). Drop the override so spans inherit the tooltip's natural color.
Astro-Han added 3 commits May 9, 2026 15:26
Todo / Revert / Followup all hand-rolled the toggle row (label + chev
IconButton). They drifted: Todo used h-9 (36, matching DESIGN.md L305);
Revert and Followup used py-2 + a 20px h-5 filler when collapsed, totalling
46-66px. The spec calls for 36 across all three widgets.

- Extract DockWidgetHeader (pl-3 pr-2 h-9 + chev slot + role=button +
  Enter/Space toggle). Caller owns label content and chev props (rotation,
  data attrs, click handlers).
- Switch all three widgets onto it. Drop the h-5 collapsed filler in Revert
  and Followup so collapsed height is 36 per spec.
- Pull leading-none onto label/preview spans inside the header so the
  text-13 line-box (line-height-large = 19.5px) collapses to the em-box;
  flex items-center then visually centers CJK glyphs.
- Polish expanded list rows: drop per-row py-1 (rows take their height from
  Button-small), tighten container pb-7 -> pb-3 to match Todo, and switch
  the inline restore button on Revert from secondary to ghost so a stack of
  rows reads as messages with quiet inline actions, not a stack of buttons.

DESIGN.md doc debt: capture the 36-row contract and the leading-none
optical-centering note so the next widget doesn't redrift.
PawWork wants submit-during-busy to land in the followup dock first, then
auto-send when the current turn ends; "send now" steers mid-stream as an
opt-in confirmation. The upstream "steer" mode (immediate submit-during-
busy with no queue UI) was the default, and a sticky migration kept
flipping any stored "queue" value back to "steer", so the followup dock
never appeared when a session was busy.

Flip the default to "queue", drop the queue->steer migration, and replace
it with a one-shot steer->queue pass to clear stored "steer" values from
prior installs (no settings UI ever surfaced this choice, so a stored
"steer" is always a migration artifact, not an explicit user preference).
The followup setting getter and setter no longer rewrite the value.

The "send now" button on the followup dock already calls the same prompt
endpoint that steer mode uses; sendFollowup's actionReady guard checks
status-known, not idle, so manual confirmation can fire mid-stream and
the backend handles the insert.
Followup and Revert duplicated their collapse spring + height observer
+ turn/off/value derivations. Extract them into useDockCollapse so both
docks share one animation contract. Todo keeps its own wiring because
it layers a dockProgress fade on top of the base collapse value.

Also pin DockWidgetHeader at h-[36px] (was h-9). This app sets root
font-size to 13px via `html { font: var(--type-body) }` in theme.css,
so Tailwind's rem-based h-9 resolves to 2.25rem * 13 = 29.25px - short
of the 30px IconButton, which made the chev overflow and clip against
the segment's overflow-y: hidden. DESIGN.md L305 commits to absolute
36px, so absolute-pixel utilities are the honest mapping.

Wrapper around the chev keeps `flex items-center` as a defensive null:
the inline-flex IconButton in a block parent could in theory pull a
line-box descender into the wrapper height; flex eliminates that path.
@coderabbitai

coderabbitai Bot commented May 9, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@Astro-Han has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 28 minutes and 36 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 60a16ac2-6870-44e9-943c-15cd1f6b0c10

📥 Commits

Reviewing files that changed from the base of the PR and between 56ed0c6 and af484c1.

📒 Files selected for processing (2)
  • packages/app/e2e/composer/composer-slice-10.spec.ts
  • packages/app/src/components/prompt-input/model-controls.tsx
📝 Walkthrough

Walkthrough

This PR refactors the composer and prompt-input system by extracting editor, routing, and interaction logic into dedicated modules, replaces the DockTray component with a new DockCard/DockSegment/DockWidgetHeader architecture with animated collapse, moves model selection from dialog-based to integrated popover-based UI, and harmonizes component sizing across the design system (32px→30px buttons, 24px→26px icons).

Changes

Prompt Input Refactoring & Dock UI Modernization

Layer / File(s) Summary
Type Definitions & Data Contracts
packages/app/src/components/prompt-input/store-types.ts, editor-imperatives.ts, comment-routing.ts, history-navigation.ts, popover-controllers.ts, keydown.ts
Defines PromptStore, HistoryStore, and public interfaces for editor imperatives, comment routing, history navigation, popover controllers, and keyboard handling.
Editor Imperatives & Serialization
packages/app/src/components/prompt-input/editor-imperatives.ts, editor-serialize.ts
Implements imperative editor control (scrolling, focus, cursor, text rendering) and bidirectional DOM↔prompt-parts serialization with pill elements for file/agent parts.
Editor Input & Routing Modules
packages/app/src/components/prompt-input/editor-input.ts, comment-routing.ts, draft-carryover.ts
Bridges DOM contenteditable with prompt store via IME composition tracking, draft carry-over, popover triggering for @mentions and /commands, and comment-based navigation to files/sessions.
History Navigation & Keyboard Handling
packages/app/src/components/prompt-input/history-navigation.ts, keydown.ts
Manages prompt history persistence and navigation (up/down arrows), applies history entries with comment/selection restoration, and routes keyboard events for editor control, popover navigation, and submission.
Popover Controllers & Model Selection
packages/app/src/components/prompt-input/popover-controllers.ts, model-picker.tsx, model-controls.tsx
Centralizes @mention and /slash-command list filtering and selection, integrates model picker as popover with variant/thinking-level selection, removes DialogSelectModel dialog export.
Context Items & Send Button
packages/app/src/components/prompt-input/context-items.tsx, send-button.tsx
Refactors context item chip rendering with range helper and tooltip improvements, updates send button with data-state attribute and new h-30px/w-30px sizing.
PromptInput Component Refactoring
packages/app/src/components/prompt-input.tsx
Delegates editor, routing, history, input, popover, and keyboard logic to extracted modules; updates container from DockShellForm to DockSegmentForm; changes attach button to IconButton; simplifies model control rendering.
Dock Component System
packages/ui/src/components/dock-card.tsx, dock-card.css, use-dock-collapse.ts, dock-widget-header.tsx
Introduces DockCard, DockSegment, DockSegmentForm base components; adds DockWidgetHeader for collapsible headers with fixed 36px height; implements useDockCollapse hook with animated max-height interpolation.
Dock Component Migration
packages/app/src/pages/session/composer/session-todo-dock.tsx, session-followup-dock.tsx, session-revert-dock.tsx, session-permission-dock.tsx
Migrates todo, followup, and revert docks from DockTray to DockCard/DockSegment/DockWidgetHeader with useDockCollapse; refactors SessionPermissionContent to use div-based data-slot markup.
SessionComposerRegion Integration
packages/app/src/pages/session/composer/session-composer-region.tsx
Simplifies region by removing resize/animation state; uses DockCard/DockSegment directly; integrates dock components and PromptInput with permission request handling and child-mode fallback.
UI Component Sizing Harmonization
packages/ui/src/components/*.css, packages/app/src/components/*, packages/app/src/pages/*
Systematically updates button/trigger heights from 32px to 30px, icon sizes from 24px to 26px across many component CSS files and component classes for consistent design system alignment.
Sidebar & Layout Updates
packages/app/src/components/titlebar.tsx, session-header.tsx, pages/layout.tsx, pages/layout/pawwork-sidebar.tsx, sidebar-items.tsx, sidebar-workspace.tsx
Updates sidebar button heights, session row heights, workspace actions sizing, titlebar heights, and main layout with consistent 30px/26px sizing; removes decorative borders and background classes.
Settings, i18n & Theme Updates
packages/app/src/context/settings.tsx, i18n/en.ts, i18n/zh.ts, index.css, pages/layout.tsx
Changes followup default from "steer" to "queue" with migration effect; adds i18n entries for model variant "Thinking"; updates dark-mode shell colors and titlebar gradient; removes decorative borders.
Tests, Specs & Cleanup
packages/app/e2e/composer/composer-slice-10.spec.ts, prompt-input/draft-carryover.test.ts, dialog-select-model-unpaid.tsx
Adds Playwright spec for composer UI (card wrapping, send button visibility/state, model trigger popover); adds unit tests for draft carry-over semantics; removes unpaid model dialog component (145 lines).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

enhancement, P1, app, ui

Poem

🐰 The composer hops with modular grace,

Each helper claims its rightful place,
Docks collapse with springy cheer,
Popovers pop—no dialogs here!
From 32 to 30, design is whole.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/slice-10-composer

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the composer region by modularizing the PromptInput logic and introducing a new DockCard architecture for session widgets. It also implements a draft carryover feature and unifies UI dimensions across various components. Feedback suggests using reactive accessors for resize observation to follow SolidJS best practices, moving module-level UI state to a more local context to avoid singleton issues, and refining the draft carryover logic to prevent the loss of rich content pills.

Comment thread packages/app/src/pages/session/composer/use-dock-collapse.ts Outdated
Comment thread packages/app/src/components/prompt-input/model-picker.tsx
Comment thread packages/app/src/components/prompt-input/editor-input.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (8)
packages/ui/src/components/picker.test.ts (1)

54-79: ⚡ Quick win

Add assertions for the new nested picker/list rules.

picker.css now adds [data-component="list"], [data-slot="list-header"], and [data-slot="list-group"] rules inside [data-picker-content], but this suite still only pins the older content contract. A few source-text assertions for those selectors would keep the new picker behavior from regressing silently.

🤖 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/picker.test.ts` around lines 54 - 79, Update the
tests in picker.test.ts that target the "[data-picker-content]" block to also
assert presence of the new nested rules: check that the sliced CSS block for
"[data-picker-content] {" contains the selectors and/or declarations for
[data-component="list"], [data-slot="list-header"], and
[data-slot="list-group"]; add a few assertions mirroring the existing pattern
(find contentIdx via css.indexOf("[data-picker-content] {"), slice the block,
then expect(block).toContain(...) for each of those three selectors or their key
declarations) so the new nested picker/list rules are covered.
packages/app/src/components/prompt-input/popover-controllers.ts (1)

129-147: 💤 Low value

promptProbe.select runs outside the promptEnabled() guard.

promptProbe.set / promptProbe.clear are wrapped in if (promptEnabled()) (lines 170–182), but promptProbe.select(cmd.id) on line 132 is invoked unconditionally on every selection. If the probe relies on state set inside the guard, this could either no-op or store data into a probe that isn’t supposed to be active in production builds. Consider mirroring the guard:

Suggested fix
-    promptProbe.select(cmd.id)
+    if (promptEnabled()) promptProbe.select(cmd.id)
🤖 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/components/prompt-input/popover-controllers.ts` around lines
129 - 147, promptProbe.select(cmd.id) is being called unconditionally even
though other probe calls (promptProbe.set / promptProbe.clear) are guarded by
promptEnabled(); wrap the select call in the same promptEnabled() check so the
probe is only used when probes are enabled. Locate the handleSlashSelect
function and add a conditional around promptProbe.select (and any other probe
interactions) using promptEnabled() before calling promptProbe.select(cmd.id),
mirroring the existing guards around promptProbe.set and promptProbe.clear.
packages/app/src/pages/session/use-session-commands.tsx (1)

272-276: 💤 Low value

openModelPicker() may leave externalOpen=true orphaned when no popover is mounted.

Because the open state lives in a module-level signal in model-picker.tsx, invoking model.choose while no ModelSelectorPopover is mounted (e.g. command palette fired before the composer renders, or before a session is active) will leave externalOpen stuck at true, causing the popover to spring open the next time it mounts. Consider clearing the flag with a fallback timeout, or only setting it after asserting a trigger is mounted (e.g. via a registry of mounted instances).

🤖 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/session/use-session-commands.tsx` around lines 272 -
276, The call in chooseModel that invokes openModelPicker can leave the
module-level signal externalOpen in model-picker.tsx stuck true if no
ModelSelectorPopover is mounted; update openModelPicker (or add a helper in
model-picker.tsx) to either (a) only set externalOpen after confirming a mounted
popover instance via a lightweight registry/flag maintained by
ModelSelectorPopover on mount/unmount, or (b) set externalOpen but schedule a
fallback clear (e.g., short timeout) to reset it if no popover registers as
mounted; modify chooseModel only if needed to call the new safe API so
externalOpen cannot become orphaned.
packages/app/src/components/prompt-input/keydown.ts (1)

184-190: 💤 Low value

Inconsistent control flow between at and slash popover branches.

The at branch ends with event.preventDefault(); return, while the slash branch falls through to the shared event.preventDefault(); return two lines below. This works today, but it's easy to break when adding a third popover kind. Mirroring the at branch keeps the two paths visually symmetric:

Suggested cleanup
-        if (store.popover === "slash") {
-          slashOnKeyDown(event)
-        }
-        event.preventDefault()
-        return
+        if (store.popover === "slash") {
+          slashOnKeyDown(event)
+          event.preventDefault()
+          return
+        }
+        event.preventDefault()
+        return
🤖 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/components/prompt-input/keydown.ts` around lines 184 - 190,
The control flow for the popover handling is asymmetric: when store.popover ===
"at" the code calls the handler then does event.preventDefault(); return, but
the "slash" branch calls slashOnKeyDown(event) and relies on the shared
event.preventDefault()/return below; make the branches symmetric by adding
event.preventDefault(); return immediately after slashOnKeyDown(event) so both
store.popover === "at" and store.popover === "slash" explicitly call their
handler and then call event.preventDefault() and return.
packages/app/src/components/prompt-input/editor-imperatives.ts (1)

10-32: 💤 Low value

inset is captured by value, not as an accessor.

editorRef, scrollRef, prompt, platform are reactive/lazy, but inset: number is read once at createEditorImperatives invocation. If the call site ever derives inset from a responsive layout (e.g. dock height changing with breakpoints), scrollCursorIntoView on line 63 will use a stale value. Consider typing it as inset: () => number to match the rest of the deps surface.

🤖 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/components/prompt-input/editor-imperatives.ts` around lines
10 - 32, The inset value is captured by value causing stale reads; change the
EditorImperativesDeps type to declare inset: () => number (make it an accessor
like editorRef/scrollRef), update createEditorImperatives to destructure and use
the accessor (call inset() wherever the numeric inset is needed — e.g., inside
scrollCursorIntoView and any other references), and update all call sites that
construct EditorImperativesDeps to pass a function returning the current inset
instead of a number so the imperative functions always read the latest value.
packages/app/src/components/prompt-input/model-picker.tsx (1)

22-26: 💤 Low value

Module-level signal couples all ModelSelectorPopover instances.

externalOpen is declared at module scope, so calling openModelPicker() will open every mounted ModelSelectorPopover simultaneously. Today there is effectively a single instance, but if the composer ever renders more than one (e.g. side-by-side sessions, the new hybrid composer shell, or a preview panel) this becomes a stealth bug. Consider scoping the open command to an instance, e.g. by registering the popover into a small registry on mount and exposing openModelPicker() as a dispatcher to the focused/last-mounted instance.

🤖 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/components/prompt-input/model-picker.tsx` around lines 22 -
26, The module-level signal externalOpen and its setter setExternalOpen are
causing every ModelSelectorPopover to open when openModelPicker() is called;
change this to instance-scoped control by registering each ModelSelectorPopover
on mount (e.g., maintain a small registry of instances or their open handlers)
and have openModelPicker() dispatch to the focused/last-mounted instance’s open
handler instead of toggling a global externalOpen; update ModelSelectorPopover
to register/unregister itself and expose an instance method or callback for
opening, and make openModelPicker() look up and call that instance handler.
packages/app/src/pages/session/composer/session-revert-dock.tsx (1)

17-19: 💤 Low value

Consider using createSignal instead of createStore for a single field.

The store object contains only one field (collapsed) and is updated independently by the toggle function. Per coding guidelines, createStore is preferred when signals represent coupled object state with shared batch updates, but for a single independent field, createSignal adds less boilerplate.

♻️ Refactor to createSignal
-  const [store, setStore] = createStore({
-    collapsed: true,
-  })
+  const [collapsed, setCollapsed] = createSignal(true)

-  const toggle = () => setStore("collapsed", (value) => !value)
+  const toggle = () => setCollapsed((value) => !value)

Then update references from store.collapsed to collapsed() throughout the component.

Based on learnings: in this repo, prefer createStore over multiple createSignal calls only when the signals represent coupled object state that is updated together in at least one shared batch-update site.

🤖 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/session/composer/session-revert-dock.tsx` around lines
17 - 19, The component currently uses createStore for a single boolean field
(store.collapsed) updated by toggle; replace createStore with createSignal to
reduce boilerplate: replace const [store, setStore] = createStore({ collapsed:
true }) with const [collapsed, setCollapsed] = createSignal(true), update the
toggle handler (toggle) to call setCollapsed(prev => !prev), and update all
usages of store.collapsed to collapsed() throughout the SessionRevertDock
component (and any imports if needed) to ensure behavior remains identical.
packages/app/src/pages/session/composer/session-followup-dock.tsx (1)

17-19: 💤 Low value

Consider using createSignal instead of createStore for a single field.

The store object contains only one field (collapsed) and is updated independently by the toggle function. Per coding guidelines, createStore is preferred when signals represent coupled object state with shared batch updates, but for a single independent field, createSignal adds less boilerplate.

♻️ Refactor to createSignal
-  const [store, setStore] = createStore({
-    collapsed: false,
-  })
+  const [collapsed, setCollapsed] = createSignal(false)

-  const toggle = () => setStore("collapsed", (value) => !value)
+  const toggle = () => setCollapsed((value) => !value)

Then update references from store.collapsed to collapsed() throughout the component.

Based on learnings: in this repo, prefer createStore over multiple createSignal calls only when the signals represent coupled object state that is updated together in at least one shared batch-update site.

🤖 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/session/composer/session-followup-dock.tsx` around
lines 17 - 19, The component currently uses createStore({ collapsed: false })
with store and setStore even though only a single independent field is tracked;
replace this with createSignal(false) (rename to collapsed and setCollapsed) and
update the toggle handler (currently calling setStore) to call setCollapsed(prev
=> !prev) (or setCollapsed(!collapsed())). Also update all usages of
store.collapsed to collapsed() and remove the deprecated setStore/store
references and imports so the component uses the new collapsed signal
everywhere.
🤖 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/src/components/prompt-input/editor-serialize.ts`:
- Around line 129-136: The block-separator code after visit() is adding an extra
newline for empty blocks because visit() can already append "\n" (e.g., for
<br>), so change the separator logic in the loop over editor.childNodes to only
append "\n" when the block is a block element (isBlock) AND the serialization
buffer does not already end with a newline (check buffer.endsWith("\n")). Update
the branch that uses isBlock and index to guard the buffer += "\n" with this
ends-with check so blocks that already contributed a trailing newline (visit())
don't get a second one; references: editor.childNodes, visit(), buffer, isBlock.

In `@packages/app/src/components/prompt-input/keydown.ts`:
- Around line 224-232: The current keydown handler contains a redundant branch:
both branches call handleSubmit(event) making the stopping() check dead code;
change the logic in the Enter-without-Shift path so it either removes the
stopping() conditional entirely and directly calls handleSubmit(event), or
(preferred) explicitly ignore submits while a stop is in progress by doing: if
(stopping()) return; else handleSubmit(event). Update the handler containing
stopping() and handleSubmit to reflect the chosen behavior (ignore or simplify)
so the intent is clear.

In `@packages/app/src/components/prompt-input/model-picker.tsx`:
- Around line 105-141: The nested Kobalte.Portal inside ThinkingLevelSection
causes clicks inside the inner picker to be treated as "outside" by the outer
popover's onPointerDownOutside, closing the model picker; update the outer
popover's onPointerDownOutside handler (the one that calls close("outside")) to
first check the event target against the inner picker's DOM (use
event.target.closest with the existing data attribute like data-picker-content
or a new shared data attribute) and return early if the target is inside the
inner picker, so pointerDownInside interactions in the portal won't dismiss the
parent; alternatively render the nested picker inline, but prefer adding the DOM
guard in onPointerDownOutside to preserve portal usage.

In `@packages/app/src/components/prompt-input/popover-controllers.ts`:
- Around line 117-127: The generated custom command IDs collide because id is
built only from cmd.name; update the ID generation in the custom mapping (the
const custom = sync.data.command.map(...) block that sets id:
`custom.${cmd.name}`) to include cmd.source as well (e.g., incorporate
cmd.source into the id alongside cmd.name) so each command gets a unique id
across sources and stops being deduped by useFilteredList.

In `@packages/app/src/components/prompt-input/send-button.tsx`:
- Around line 23-25: The merge currently drops string-typed styles because it
only spreads when typeof props.style === "object"; update the SendButton style
handling in send-button.tsx (the code that builds the style prop for the
SendButton component) to preserve string values instead of discarding them —
e.g. change the merge so it uses props.style directly when it's a string (or
passes props.style through when not an object) rather than always replacing with
{}; ensure the logic still merges object styles correctly (keep the existing
object spread for JSX.CSSProperties) and handle the string branch by returning
props.style (or otherwise forwarding it) so callers that pass a string value for
style are not silently ignored.

In `@packages/app/src/pages/session/composer/session-composer-region.tsx`:
- Around line 130-149: The composer UI is being hidden behind prompt.ready(),
which prevents users from responding to permissionRequest() that can arrive
while prompt hydration is pending; update the rendering logic in the
showComposer() branch (the fallback that currently checks prompt.ready()) to
first check for an active permissionRequest() and render the permission request
UI immediately (instead of the handoff/loading DockCard), while still keeping
the existing rolled()/SessionRevertDock and handoff loading as fallbacks; apply
the same change to the other similar block referenced (lines ~179-236) so
permissionRequest() is never blocked by prompt.ready().

In `@packages/ui/src/components/dock-card.tsx`:
- Around line 9-12: The class merging logic incorrectly treats a space-separated
split.class as a single key (e.g., "foo bar") — remove the manual merge and let
SolidJS handle combining `class` and `classList` natively: delete the
`[split.class ?? ""]: !!split.class` entry and the custom merge in `classList`
for the components using `split` (refer to the `classList` usage around
`split.class` in DockCard, DockSegment, and DockSegmentForm) so the element
receives `class={...}` and `classList={...}` separately instead of constructing
a single spaced-key map.

---

Nitpick comments:
In `@packages/app/src/components/prompt-input/editor-imperatives.ts`:
- Around line 10-32: The inset value is captured by value causing stale reads;
change the EditorImperativesDeps type to declare inset: () => number (make it an
accessor like editorRef/scrollRef), update createEditorImperatives to
destructure and use the accessor (call inset() wherever the numeric inset is
needed — e.g., inside scrollCursorIntoView and any other references), and update
all call sites that construct EditorImperativesDeps to pass a function returning
the current inset instead of a number so the imperative functions always read
the latest value.

In `@packages/app/src/components/prompt-input/keydown.ts`:
- Around line 184-190: The control flow for the popover handling is asymmetric:
when store.popover === "at" the code calls the handler then does
event.preventDefault(); return, but the "slash" branch calls
slashOnKeyDown(event) and relies on the shared event.preventDefault()/return
below; make the branches symmetric by adding event.preventDefault(); return
immediately after slashOnKeyDown(event) so both store.popover === "at" and
store.popover === "slash" explicitly call their handler and then call
event.preventDefault() and return.

In `@packages/app/src/components/prompt-input/model-picker.tsx`:
- Around line 22-26: The module-level signal externalOpen and its setter
setExternalOpen are causing every ModelSelectorPopover to open when
openModelPicker() is called; change this to instance-scoped control by
registering each ModelSelectorPopover on mount (e.g., maintain a small registry
of instances or their open handlers) and have openModelPicker() dispatch to the
focused/last-mounted instance’s open handler instead of toggling a global
externalOpen; update ModelSelectorPopover to register/unregister itself and
expose an instance method or callback for opening, and make openModelPicker()
look up and call that instance handler.

In `@packages/app/src/components/prompt-input/popover-controllers.ts`:
- Around line 129-147: promptProbe.select(cmd.id) is being called
unconditionally even though other probe calls (promptProbe.set /
promptProbe.clear) are guarded by promptEnabled(); wrap the select call in the
same promptEnabled() check so the probe is only used when probes are enabled.
Locate the handleSlashSelect function and add a conditional around
promptProbe.select (and any other probe interactions) using promptEnabled()
before calling promptProbe.select(cmd.id), mirroring the existing guards around
promptProbe.set and promptProbe.clear.

In `@packages/app/src/pages/session/composer/session-followup-dock.tsx`:
- Around line 17-19: The component currently uses createStore({ collapsed: false
}) with store and setStore even though only a single independent field is
tracked; replace this with createSignal(false) (rename to collapsed and
setCollapsed) and update the toggle handler (currently calling setStore) to call
setCollapsed(prev => !prev) (or setCollapsed(!collapsed())). Also update all
usages of store.collapsed to collapsed() and remove the deprecated
setStore/store references and imports so the component uses the new collapsed
signal everywhere.

In `@packages/app/src/pages/session/composer/session-revert-dock.tsx`:
- Around line 17-19: The component currently uses createStore for a single
boolean field (store.collapsed) updated by toggle; replace createStore with
createSignal to reduce boilerplate: replace const [store, setStore] =
createStore({ collapsed: true }) with const [collapsed, setCollapsed] =
createSignal(true), update the toggle handler (toggle) to call setCollapsed(prev
=> !prev), and update all usages of store.collapsed to collapsed() throughout
the SessionRevertDock component (and any imports if needed) to ensure behavior
remains identical.

In `@packages/app/src/pages/session/use-session-commands.tsx`:
- Around line 272-276: The call in chooseModel that invokes openModelPicker can
leave the module-level signal externalOpen in model-picker.tsx stuck true if no
ModelSelectorPopover is mounted; update openModelPicker (or add a helper in
model-picker.tsx) to either (a) only set externalOpen after confirming a mounted
popover instance via a lightweight registry/flag maintained by
ModelSelectorPopover on mount/unmount, or (b) set externalOpen but schedule a
fallback clear (e.g., short timeout) to reset it if no popover registers as
mounted; modify chooseModel only if needed to call the new safe API so
externalOpen cannot become orphaned.

In `@packages/ui/src/components/picker.test.ts`:
- Around line 54-79: Update the tests in picker.test.ts that target the
"[data-picker-content]" block to also assert presence of the new nested rules:
check that the sliced CSS block for "[data-picker-content] {" contains the
selectors and/or declarations for [data-component="list"],
[data-slot="list-header"], and [data-slot="list-group"]; add a few assertions
mirroring the existing pattern (find contentIdx via
css.indexOf("[data-picker-content] {"), slice the block, then
expect(block).toContain(...) for each of those three selectors or their key
declarations) so the new nested picker/list rules are covered.
🪄 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: 7ea7426c-0c51-4ef0-b16c-0f3322b00d8d

📥 Commits

Reviewing files that changed from the base of the PR and between 9f2275e and b9ac853.

📒 Files selected for processing (69)
  • packages/app/e2e/composer/composer-slice-10.spec.ts
  • packages/app/src/components/dialog-manage-models.tsx
  • packages/app/src/components/dialog-select-model-unpaid.tsx
  • packages/app/src/components/file-tree.tsx
  • packages/app/src/components/prompt-input.tsx
  • packages/app/src/components/prompt-input/comment-routing.ts
  • packages/app/src/components/prompt-input/context-items.tsx
  • packages/app/src/components/prompt-input/draft-carryover.test.ts
  • packages/app/src/components/prompt-input/draft-carryover.ts
  • packages/app/src/components/prompt-input/editor-imperatives.ts
  • packages/app/src/components/prompt-input/editor-input.ts
  • packages/app/src/components/prompt-input/editor-serialize.ts
  • packages/app/src/components/prompt-input/history-navigation.ts
  • packages/app/src/components/prompt-input/keydown.ts
  • packages/app/src/components/prompt-input/model-controls.tsx
  • packages/app/src/components/prompt-input/model-picker.tsx
  • packages/app/src/components/prompt-input/popover-controllers.ts
  • packages/app/src/components/prompt-input/send-button.tsx
  • packages/app/src/components/prompt-input/store-types.ts
  • packages/app/src/components/session-context-usage.tsx
  • packages/app/src/components/session/session-header.tsx
  • packages/app/src/components/session/session-new-view.tsx
  • packages/app/src/components/session/session-status-connections.tsx
  • packages/app/src/components/settings-keybinds.tsx
  • packages/app/src/components/status-popover-body.tsx
  • packages/app/src/components/status-popover.tsx
  • packages/app/src/components/titlebar.tsx
  • packages/app/src/context/settings.tsx
  • packages/app/src/i18n/en.ts
  • packages/app/src/i18n/zh.ts
  • packages/app/src/index.css
  • packages/app/src/pages/layout.tsx
  • packages/app/src/pages/layout/pawwork-sidebar.tsx
  • packages/app/src/pages/layout/pawwork-worktree-badge.tsx
  • packages/app/src/pages/layout/sidebar-items.tsx
  • packages/app/src/pages/layout/sidebar-workspace.tsx
  • packages/app/src/pages/session/composer/dock-widget-header.tsx
  • packages/app/src/pages/session/composer/session-composer-region.tsx
  • packages/app/src/pages/session/composer/session-followup-dock.tsx
  • packages/app/src/pages/session/composer/session-permission-dock.tsx
  • packages/app/src/pages/session/composer/session-revert-dock.tsx
  • packages/app/src/pages/session/composer/session-todo-dock.tsx
  • packages/app/src/pages/session/composer/use-dock-collapse.ts
  • packages/app/src/pages/session/file-tabs.tsx
  • packages/app/src/pages/session/session-main-view.tsx
  • packages/app/src/pages/session/session-side-panel.tsx
  • packages/app/src/pages/session/use-session-commands.tsx
  • packages/ui/src/components/accordion.css
  • packages/ui/src/components/button.css
  • packages/ui/src/components/collapsible.css
  • packages/ui/src/components/command-palette.css
  • packages/ui/src/components/dock-card.css
  • packages/ui/src/components/dock-card.tsx
  • packages/ui/src/components/file-search.tsx
  • packages/ui/src/components/file.css
  • packages/ui/src/components/icon-button.css
  • packages/ui/src/components/icon.tsx
  • packages/ui/src/components/line-comment-styles.ts
  • packages/ui/src/components/list.css
  • packages/ui/src/components/menu.css
  • packages/ui/src/components/message-part.css
  • packages/ui/src/components/picker.css
  • packages/ui/src/components/picker.test.ts
  • packages/ui/src/components/popover.css
  • packages/ui/src/components/session-review.tsx
  • packages/ui/src/components/session-turn.css
  • packages/ui/src/components/tabs.css
  • packages/ui/src/components/text-field.css
  • packages/ui/src/styles/index.css
💤 Files with no reviewable changes (1)
  • packages/app/src/components/dialog-select-model-unpaid.tsx

Comment thread packages/app/src/components/prompt-input/editor-serialize.ts
Comment thread packages/app/src/components/prompt-input/keydown.ts
Comment thread packages/app/src/components/prompt-input/model-picker.tsx
Comment thread packages/app/src/components/prompt-input/popover-controllers.ts
Comment thread packages/app/src/components/prompt-input/send-button.tsx
Comment thread packages/app/src/pages/session/composer/session-composer-region.tsx
Comment thread packages/ui/src/components/dock-card.tsx Outdated
Astro-Han added 6 commits May 9, 2026 20:01
The two action buttons live next to each other but had drifted apart:
SendButton was h-[30px] / size-4 (icon), ContextUsage was size-7 /
rounded-xl! (button) with a hardcoded ProgressCircle size 16. In this
13px-rem app, Tailwind size-N resolves to N * 0.25rem * 13 = N * 3.25px,
so size-4 = 13 (not the 16 the design intended) and size-7 = 22.75
(not 28). The pair rendered as a 30 circle with a 13 icon next to a
22.75 circle with a 16 ring - drift in both outer and inner.

Pin both ends to absolute pixels so the chrome contract is honest:
30 outer + 16 inner indicator + 7+7 padding, both buttons mirrored.

Same systemic root cause as the dock header h-9 fix: Tailwind
unit-spacing is not the 4pt grid this codebase's design contracts
assume. Tracked for the slice 10 docs PR.
Commit 81aa587 (joined-card dock refactor) hardcoded dockProgress={1}
on SessionTodoDock and removed the parent-side spring that drove it,
so the dock popped in the moment props.state.dock() flipped true
instead of sliding/fading in from 0.

Restore the mount animation:
- session-composer-region: re-introduce a useSpring tracking dockOpen,
  keep the segment mounted while it animates out (dockMounted memo),
  pass the spring value through as dockProgress.
- session-todo-dock: multiply dock() into the max-height expression
  so the segment slides 0 → collapsed-height (or 0 → full when
  mounting in expanded state), in addition to the existing opacity
  drive that was already wired.

Composer-dock spring options now appear in three places (Todo
collapse, useDockCollapse for Followup/Revert, and segment mount).
Extract them into a single DOCK_MOTION constant in composer/motion.ts
so the contract is referenceable, not scattered. Pinning the value
(0.3s, bounce:0) is the de facto standard across composer springs;
hoisting to a global SPRING_TOKENS tier ladder and wiring spring
through prefers-reduced-motion are tracked in #34.
…e2e assertion

CI typecheck failure: createPopoverControllers returns at.active /
at.setActive / slash.active / slash.setActive verbatim from
useFilteredList, which exposes solid-list's createList signals as
Accessor<string | null> / Setter<string | null>. The factory return
interface declared them as string | undefined, which is not assignable.
Consumer (prompt-input.tsx) already wraps reads with ?? undefined,
confirming the null was always there and the interface was overstated.
Pin the interface to string | null so the types match runtime.

E2E failure: session.spec.ts asserted [data-component="prompt-variant-
control"] is visible, but variant selection has been folded into the
model picker popover (see prompt-input/model-controls.tsx top-of-file
note). The separate variant trigger no longer renders. Drop the stale
assertion.
Critical:
- dock-card: stop concatenating split.class into a single classList key.
  "foo bar" was becoming a literal token and tripping
  Element.classList.toggle (DOMException). Solid merges class+classList
  natively when both are passed; restored to that idiom for DockCard,
  DockSegment, and DockSegmentForm.

Major:
- editor-serialize: skip the block-separator newline when visit() has
  already contributed one (e.g. an empty <div><br></div>). Fixes
  multiline draft round-trips that were inflating "foo\n\nbar" into
  "foo\n\n\nbar".
- model-picker: nested ThinkingLevel popover renders into Kobalte.Portal,
  so its content sits outside the outer Content's DOM subtree. The
  outer popover's onPointerDownOutside fired on every inner-popover
  click and dismissed the model picker. Both popovers carry
  data-picker-content; outer now ignores the dismiss when
  event.target.closest("[data-picker-content]") matches a nested
  picker.
- session-composer-region: permission requests can arrive while
  prompt.ready() is still false (e.g. switching into a session whose
  prompt is hydrating). The hydrate fallback used to show only the
  loading card, leaving the user no way to approve or deny. The
  fallback now renders SessionPermissionContent when a request is
  pending and falls through to the loading card otherwise.
- send-button: SendButtonProps declared style?: JSX.CSSProperties |
  string but the merge silently dropped the string branch. No caller
  passes a string today, so narrow the prop type to JSX.CSSProperties
  to match actual behavior instead of inventing a string-merge code
  path.

Minor:
- keydown: the Enter-without-Shift branch had if (stopping())
  handleSubmit / else handleSubmit — both arms identical, so the
  conditional was dead. Inlined to a single handleSubmit call. The
  stopping accessor stays in scope for the keydown probe.
- popover-controllers: include cmd.source in the custom slash command
  id so workspace + user configs sharing a command name don't collapse
  into one entry under useFilteredList.

Medium (gemini):
- use-dock-collapse: contentRef is now a signal driven by the JSX ref
  callback, and createResizeObserver consumes that signal directly
  rather than running inside a createEffect. Idiomatic and tracks
  ref re-assignment correctly.

Push back:
- model-picker module-level externalOpen / openModelPicker(): the
  signal is a deliberate imperative API consumed by use-session-commands
  and the prompt-input keybind; multi-composer support would require
  reshaping the call surface, not just the state. YAGNI for the single
  composer this app has today; tracked under #34.
- editor-input draft carryover preserves text only: pre-existing
  intentional design from PR #185 (commit ea01514 "carry plain-text
  draft across directory switch"). Cross-workspace pill resolution
  needs workspace-aware path semantics — out of slice 10 scope.
The trigger span used max-w-[7rem] (= 91px in this 13px-rem app), which
truncated common longer names like "DeepSeek V4 Pro" or "Claude Sonnet
4.6" while leaving no min, so neighbor controls (workspace chip, +
button) jumped by the full content-width swing whenever the active
model changed.

Switch to absolute pixels with both bounds: min-w-[80px] gives a
stable floor that swallows the 38-65px range covering common names
like "GPT-5.5", "Kimi K2.6", "GLM-5.1" with a small natural gutter,
without inflating them visibly. max-w-[180px] cleanly fits all
17-character display names with truncate as the safety net beyond.
The neighbor-shift budget tightens from ~140px to ~100px while
short names stop looking padded.

The @max-[28rem]/composer:max-w-0 collapse rule (icon-only mode in
narrow composers) is preserved.
The composer-slice-10 spec asserted boundingBox 32px but the actual
SendButton renders h-[30px] w-[30px] and DESIGN.md commits to the 30
dense tier. The assertion slipped through CI because it lacks the
@smoke tag the smoke job filters on; it would have failed the moment
someone ran the full e2e shard. Caught in external code review.

Push back deferred: openModelPicker() module-level externalOpen as
P3 follow-up — same reasoning as the prior round. The single-composer
path cleans externalOpen on onOpenChange(false), and a multi-composer
fix needs a mounted registry / focus-aware dispatch redesign rather
than a state shape tweak. Tracked under issue #34.
@Astro-Han Astro-Han merged commit b136aaf into dev May 9, 2026
20 checks passed
@Astro-Han Astro-Han deleted the claude/slice-10-composer branch May 9, 2026 13:32
Astro-Han added a commit that referenced this pull request May 10, 2026
Root cause:
- PR #508 made DockCard clip overflow. Prompt popovers were still rendered inside the prompt composer card, so slash commands and @mention file suggestions could be visually clipped even when their trigger/state logic was correct.
- During manual Electron verification, several early fix attempts were tested against the wrong renderer source because the worktree had symlinked node_modules resolving @opencode-ai/app back to the main checkout. That made the first debug rounds look like the fix code was ineffective when it had not actually loaded in the tested app.

Fix:
- Keep PromptPopover anchored inside PromptInput so the existing geometry and keyboard behavior stay intact.
- Give only the prompt composer DockCard a local overflow-visible opt-out, rather than changing the global DockCard contract.
- Preserve the original max-h-80 / above-input visual boundary and add pointer/z-index handling so slash and @mention rows remain visible, hoverable, and selectable.
- Add regression E2E coverage for session slash commands, home slash commands, and @mention file suggestions. The tests assert viewport gap, topmost element hit-testing, and hover paint so this does not regress silently.

Review follow-up:
- Gemini's old Portal / ResizeObserver positioning thread was resolved as obsolete after the implementation was simplified back to the local overflow opt-out.
- CodeRabbit's inline ref-cast comment was addressed by passing props.inputRef directly.
- CodeRabbit's direct positioning-constant nit applied to the removed fixed-position implementation and is obsolete on the final head.

Verification:
- CI on PR #525: all checks passing, including typecheck, unit shards, e2e-artifacts, smoke-macos-arm64, CodeQL, CodeRabbit, lint-commits, and review checks.
- Review threads: all resolved.
- Local verification after final amend: bun --cwd packages/app typecheck; bun --cwd packages/app test:e2e -- e2e/prompt/prompt-slash-open.spec.ts; git diff --check.
- Manual Electron verification by AstroHan: home /, home @, session /, session @, and multi-segment prompt states all behaved normally.

Follow-up:
- The @mention/file popover visual design may need a separate DESIGN.md alignment pass. That is intentionally left out of this hotfix so #524 stays scoped to visibility/interactivity.

Closes #524.
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