Skip to content

fix(app): stabilize session composer dock scrolling#354

Open
Astro-Han wants to merge 17 commits intodevfrom
codex/fix-i351-scroll-dock
Open

fix(app): stabilize session composer dock scrolling#354
Astro-Han wants to merge 17 commits intodevfrom
codex/fix-i351-scroll-dock

Conversation

@Astro-Han
Copy link
Copy Markdown
Owner

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

Summary

  • Fix session timeline bottom scrolling by centralizing composer dock height measurement and sticky-bottom behavior.
  • Tighten the latest-turn bottom padding so the last message tail sits closer to the Composer.
  • Split packages/app/src/pages/session.tsx into focused session controllers with unit coverage.

Why

session.tsx mixed route composition, scroll dock layout, history windowing, desktop context sync, followup drafts, revert flow, and review state. The scroll bug came from duplicated dock measurement and bottom-padding paths, which could leave the latest message underneath the Composer after the dock height changed.

Related Issue

Fixes #351

Human Review Status

Pending maintainer review.

Review Focus

  • use-session-scroll-dock.ts: sticky-bottom behavior, composer dock height sync, user-scroll preservation.
  • session.tsx: integration wiring after controller extraction.
  • use-session-followups.ts and use-session-review-state.ts: extracted state ownership and unchanged behavior.
  • session-composer-dock.spec.ts: regression coverage for the covered latest-turn case.

Risk Notes

  • Medium surface area because this splits a large route file into controllers.
  • No intended product behavior change outside the composer dock scroll fix and smaller latest-turn bottom gap.
  • One controller remains above the soft 200-line target because the extracted history window logic is still a single coherent unit.

How To Verify

  • bun test --preload ./happydom.ts src/pages/session/use-session-scroll-dock.test.ts src/pages/session/session-auto-scroll.test.ts src/pages/session/use-session-history-window.test.ts src/pages/session/use-session-desktop-context.test.ts src/pages/session/use-session-followups.test.ts src/pages/session/use-session-revert.test.ts src/pages/session/use-session-review-state.test.ts src/pages/session/review-change-mode.test.ts src/pages/session/session-messages.test.ts src/components/prompt-input/submit.test.ts
    • Result: 42 passed, 0 failed.
  • bun typecheck
    • Result: passed.
  • bun test:e2e:local -- --grep "composer dock keeps latest turn visible"
    • Result: 1 passed.
  • git diff --check
    • Result: passed with no whitespace errors.

Screenshots or Recordings

  • Not attached. The targeted E2E generates a local screenshot artifact for the latest-turn visibility assertion and the artifact was removed from the worktree after the run.

Checklist

Summary by CodeRabbit

  • Refactor

    • Reorganized session page internals for cleaner state management and composable-driven UI wiring.
  • New Features

    • Improved scroll/auto-scroll behavior with a resizable composer dock, active-message navigation, followup queueing/auto-send, revert/restore controls, inline comment context, review panel and session timeline composition.
  • Tests

    • Added extensive E2E and unit tests covering scroll docking, desktop sync, followups, history windowing, revert, review state, and related helpers.
  • Style

    • Reduced session timeline bottom padding for tighter layout.

@Astro-Han Astro-Han added P2 Medium priority app Application behavior and product flows ui Design system and user interface labels May 1, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 1, 2026

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 9b2b9ebe-c42b-4928-94dc-0b17c6955711

📥 Commits

Reviewing files that changed from the base of the PR and between 765de25 and dd0b7f8.

📒 Files selected for processing (12)
  • packages/app/src/pages/session.tsx
  • packages/app/src/pages/session/session-composer-region.tsx
  • packages/app/src/pages/session/session-main-view.tsx
  • packages/app/src/pages/session/use-session-active-message.ts
  • packages/app/src/pages/session/use-session-comment-context.test.ts
  • packages/app/src/pages/session/use-session-comment-context.ts
  • packages/app/src/pages/session/use-session-keyboard-focus.ts
  • packages/app/src/pages/session/use-session-refresh-effects.ts
  • packages/app/src/pages/session/use-session-review-panel.tsx
  • packages/app/src/pages/session/use-session-route-tabs.ts
  • packages/app/src/pages/session/use-session-timeline-data.ts
  • packages/app/src/pages/session/use-session-vcs-refresh.ts
 _________________________________________
< 💣 Deploying bug fixes in 3... 2... 1... >
 -----------------------------------------
  \
   \   \
        \ /\
        ( )
      .( o ).
📝 Walkthrough

Walkthrough

This PR extracts session-page controllers into dedicated composable hooks to fix a composer dock scrolling bug where height changes sometimes fail to compensate scroll position. It introduces six new hook modules managing scroll-dock layout, desktop context sync, review state, follow-ups queuing, history windowing, and revert/restore mutations, reducing session.tsx from 2,145 to ~1,250 lines and establishing clear ownership boundaries.

Changes

Cohort / File(s) Summary
Composer Dock Scroll Fix
packages/app/src/pages/session/use-session-scroll-dock.ts, packages/app/src/pages/session/use-session-scroll-dock.test.ts
New module owning composer dock height measurement, CSS variable syncing, scroll state calculation, and bottom-scroll compensation on dock resize. Exports helpers for overflow detection, sticky-to-bottom logic, and height synchronization with DOM-side effects. Tests verify scroll state metrics and dock-resize behavior.
Desktop Context Synchronization
packages/app/src/pages/session/use-session-desktop-context.ts, packages/app/src/pages/session/use-session-desktop-context.test.ts
New hook managing async desktop context sender with deduplication, exponential backoff retry (up to 5 attempts, 4000ms cap), and state disposal. Tests verify no-send when sender absent, deduplication on repeated context, and retry scheduling on transient failures.
Review State Management
packages/app/src/pages/session/use-session-review-state.ts, packages/app/src/pages/session/use-session-review-state.test.ts
New module deriving review artifact files from cached history or turn diffs, managing review mode (changes), computing review readiness, and fetching VCS diffs per mode with deduplication and stale-response suppression. Tests validate artifact preference logic and fallback filtering.
Follow-up Queue Management
packages/app/src/pages/session/use-session-followups.ts, packages/app/src/pages/session/use-session-followups.test.ts
New composable tracking queued follow-up drafts with persistence, computing preview text and auto-send gating predicates, sending drafts via mutation with retry and pause support. Tests verify preview-text extraction and auto-send conditions (not busy, failed, paused, blocked, or in child session).
Message History Windowing
packages/app/src/pages/session/use-session-history-window.ts, packages/app/src/pages/session/use-session-history-window.test.ts
New controller computing bounded message window (turnStart offset and renderedUserMessages slice), supporting prefetch with rate limiting and user-driven backfill, and scroll-preservation on state changes. Tests validate window bounds and rendered message ranges.
Revert and Restore Mutations
packages/app/src/pages/session/use-session-revert.ts, packages/app/src/pages/session/use-session-revert.test.ts
New module exporting rolledRevertItems and createSessionRevert for deriving revert/restore targets and orchestrating dual async mutations (session.revert/session.unrevert) with prompt snapshot, batch state updates, and error rollback. Tests verify item derivation and revert list construction.
Session Page Refactoring
packages/app/src/pages/session.tsx
Major refactor replacing 876 lines of inline state machines and effects with imports of useSessionDesktopContext, createSessionReviewState, createSessionFollowups, createSessionRevert, and createSessionScrollDock. Delegates scroll, review, follow-ups, history, and desktop-context logic; removes duplicate store fields and manual resource/mutation setup.
Timeline Padding Adjustment
packages/app/src/pages/session/message-timeline.tsx
Reduces bottom padding from pb-16 to pb-8 to address excessive final-turn gap.
E2E Tests
packages/app/e2e/session/session-composer-dock.spec.ts
Adds test for composer dock height-change scroll compensation: verifies scroll metrics before/after dock resize, checks that distance from bottom is preserved within tolerance, and captures screenshot artifact.
Test Mock Update
packages/app/src/components/prompt-input/submit.test.ts
Extends mocked @opencode-ai/util/encode with checksum function returning stringified input length.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant ScrollDock as ScrollDock Hook
    participant DOM
    participant Scroll as RAF/Timer
    
    User->>User: Resize composer dock
    User->>ScrollDock: setPromptDockRef() observes height change
    ScrollDock->>ScrollDock: Calculate previousDockHeight
    ScrollDock->>ScrollDock: shouldStickToBottomAfterDockResize()
    
    alt User scrolled upward
        ScrollDock->>DOM: scheduleScrollState() (no forced scroll)
    else User at bottom
        ScrollDock->>DOM: setCssHeight(nextDockHeight)
        ScrollDock->>Scroll: forceScrollToBottom() via RAF
        Scroll->>DOM: scrollTop = scrollHeight
        ScrollDock->>DOM: Update --composer-dock-height CSS var
    end
    
    DOM->>Scroll: scheduleScrollState() on RAF
    Scroll->>ScrollDock: calculateSessionScrollState() updates store
    ScrollDock->>User: Timeline reflows, latest message visible above dock
Loading
sequenceDiagram
    participant Page as Session Page
    participant FollowupHook as Followups Hook
    participant Sync as Sync/Storage
    participant Client as SDK Client
    participant Session as Server Session
    
    Page->>FollowupHook: createSessionFollowups({ sessionID, busy, blocked, ...})
    
    loop Auto-send gating (createEffect watches queue + conditions)
        FollowupHook->>FollowupHook: Check shouldAutoSendFollowup predicate
        
        alt Conditions met (session ready, item queued, not busy/blocked/failed/paused)
            FollowupHook->>Client: useMutation(sendFollowupDraft)
            Client->>Session: POST /followup
            Session->>Session: Process draft
            
            alt Success
                Session-->>Client: OK
                Client-->>FollowupHook: Remove from queue
                FollowupHook->>Sync: Persist updated queue to workspace
            else Failure
                Session-->>Client: Error
                Client-->>FollowupHook: Mark item failed
            end
        else Conditions not met
            FollowupHook->>FollowupHook: Wait (busy, blocked, paused, or no item)
        end
    end
    
    Page->>Page: UI binds followups state (queue, sending, failed)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

bug, refactor, session-page

Poem

🐰 A rabbit's ode to order:

Five hooks now hold what once was tangled thread,
Composers dock, their heights no longer dread—
Scroll stays smooth when pixels rearrange,
Followups queue, reviews and reverts change.
One page becomes a stage for many parts.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(app): stabilize session composer dock scrolling' directly addresses the main bug fix (composer dock scrolling) from the linked issue #351.
Description check ✅ Passed The PR description covers summary, rationale, related issue link, human review status, review focus areas, risk notes, verification steps with results, and explicitly states no screenshots (with explanation). It follows the template structure and provides meaningful context.
Linked Issues check ✅ Passed The changes directly address issue #351's core objectives: centralizing composer dock measurement/ownership, fixing bottom-scroll bug, extracting scroll-dock controller with unit tests, reducing timeline bottom padding, and splitting session.tsx into focused controllers.
Out of Scope Changes check ✅ Passed All changes are scoped to issue #351: scroll-dock controller, followups/review/revert/history controllers, session.tsx refactoring, and related tests. No unrelated refactors, backend changes, or SessionSidePanel complexity introduced.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/fix-i351-scroll-dock

Review rate limit: 9/10 reviews remaining, refill in 6 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

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

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 session page by extracting complex logic into modular hooks, including management for desktop context, followups, history windows, revert actions, review states, and scroll dock behavior. It also introduces a new E2E test for composer dock scrolling. The review feedback highlights several improvement opportunities: ensuring SolidJS ref callbacks handle null/undefined values to prevent runtime errors, clearing global CSS variables during component cleanup to avoid side effects, and replacing 'any' types with concrete TypeScript interfaces in the new hooks to restore type safety.

Comment on lines +149 to +153
const setPromptDockRef = (el: HTMLDivElement) => {
promptDock = el
const next = Math.ceil(el.getBoundingClientRect().height)
if (next > 0) updateDockHeight(next)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The setPromptDockRef callback is missing a null check before accessing getBoundingClientRect(). SolidJS calls ref callbacks with undefined when the element is unmounted, which will cause a runtime error. Additionally, the parameter type should include undefined to correctly reflect SolidJS ref behavior.

Suggested change
const setPromptDockRef = (el: HTMLDivElement) => {
promptDock = el
const next = Math.ceil(el.getBoundingClientRect().height)
if (next > 0) updateDockHeight(next)
}
const setPromptDockRef = (el: HTMLDivElement | undefined) => {
promptDock = el
if (!el) return
const next = Math.ceil(el.getBoundingClientRect().height)
if (next > 0) updateDockHeight(next)
}
References
  1. In SolidJS, handle potential undefined values in refs and memos during component disposal or unmounting to prevent runtime errors.

Comment on lines +130 to +134
const setContentRef = (el: HTMLDivElement) => {
content = el
autoScroll.contentRef(el)
if (scroller) scheduleScrollState(scroller)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The setContentRef callback should handle undefined values and update its parameter type accordingly to match SolidJS ref callback signatures. It is also safer to only schedule a scroll state update if the element is present.

Suggested change
const setContentRef = (el: HTMLDivElement) => {
content = el
autoScroll.contentRef(el)
if (scroller) scheduleScrollState(scroller)
}
const setContentRef = (el: HTMLDivElement | undefined) => {
content = el
autoScroll.contentRef(el)
if (el && scroller) scheduleScrollState(scroller)
}
References
  1. In SolidJS, handle potential undefined values in refs and memos during component disposal or unmounting to prevent runtime errors.

Comment on lines +189 to +191
onCleanup(() => {
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The global CSS variable --composer-dock-height is set on document.documentElement but never cleared. This can lead to stale layout values if the user navigates to other pages that might inadvertently use this variable. It should be removed during cleanup to ensure side-effect isolation.

Suggested change
onCleanup(() => {
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
})
onCleanup(() => {
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
document.documentElement.style.removeProperty("--composer-dock-height")
})
References
  1. Any modification to global state must be restored in a cleanup hook to ensure isolation and prevent side-effects across the application lifecycle.

Comment on lines +36 to +44
export function createSessionReviewState(input: {
directory: string
sessionKey: () => string
sessionID: () => string | undefined
sync: any
sdk: any
wantsReview: () => boolean
turnDiffs: () => any[]
}) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The createSessionReviewState function uses any for several parameters (sync, sdk, turnDiffs), which bypasses TypeScript's type checking. This hook should use concrete types (e.g., ReturnType<typeof useSync>, ReturnType<typeof useSDK>, and VcsFileDiff[]) to maintain type safety and improve maintainability, following the pattern established in use-session-followups.ts.

Comment on lines +18 to +45
export function createSessionRevert(input: {
sessionID: () => string | undefined
revertMessageID: () => string | undefined
timelineUserMessages: () => UserMessage[]
lineText: (id: string) => string
prompt: {
current: () => any[]
set: (value: any[]) => void
reset: () => void
}
sync: {
data: { message: Record<string, unknown> }
session: {
get: (sessionID: string) => { revert?: { messageID: string } } | undefined
}
}
client: {
session: {
revert: (request: { sessionID: string; messageID: string }) => Promise<{ data?: any }>
unrevert: (request: { sessionID: string }) => Promise<{ data?: any }>
}
}
halt: (sessionID: string) => Promise<unknown>
draft: (id: string) => any[]
fail: (err: unknown) => void
merge: (next: any) => void
roll: (sessionID: string, next: any) => void
}) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The createSessionRevert function signature relies heavily on any types for its input configuration. This is a regression in type safety compared to the original implementation in session.tsx. It is recommended to use proper types for the prompt, sync, client, and callback functions to ensure the refactored code remains robust and easy to maintain.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/app/e2e/session/session-composer-dock.spec.ts`:
- Around line 969-988: The test reads `afterUserScroll` immediately after
`dock.open(...)`, which can race with dock resize/scroll; change the direct
`page.evaluate` call to poll until the viewport metrics settle using
Playwright's `expect.poll` (or `expect(...).poll`) to repeatedly evaluate the
same function that queries '[data-component="scroll-viewport"]' and returns `{
scrollTop, distanceFromBottom }`, and assert/poll for `scrollTop` to be <
`before` and `distanceFromBottom` to be >= `distanceBeforeExpansion - 40` (or
poll `distanceFromBottom` first then assert `scrollTop`) so the test waits for
the dock's resize/scroll compensation before making final assertions on
`afterUserScroll`.

In `@packages/app/src/pages/session/use-session-revert.test.ts`:
- Around line 10-18: The test and implementation rely on lexicographic
comparison of message IDs causing incorrect slicing; update the
rolledRevertItems implementation to find the index of the message whose id
equals revertMessageID and slice the messages array by index (i.e., include
items after that index) instead of using string comparison (remove any `id >=
revertMessageID` style logic in rolledRevertItems), and update the
use-session-revert.test.ts case to use non-lexical IDs (e.g., "x1","a2","b3" or
random GUID-like strings) to assert index-based behavior matches expected order.

In `@packages/app/src/pages/session/use-session-review-state.ts`:
- Around line 45-47: The state uses createStore for a single independent field
"changes"; replace the createStore/createStore-set pattern with a
createSignal<ReviewChangeMode>("turn") to simplify state: remove createStore and
setStore, introduce a signal (e.g., const [changes, setChanges] =
createSignal<ReviewChangeMode>("turn")) and update all usages of store.changes
and setStore to use changes() and setChanges(...) respectively; keep the
ReviewChangeMode type and behavior unchanged.
- Around line 179-191: The two createEffect blocks (watching
input.sessionID()/input.turnDiffs() and input.sync.data.session_diff[id]) can
both call refetchArtifactHistory() and cause duplicate fetches; consolidate them
into a single createEffect that reads input.sessionID(), input.turnDiffs(), and
input.sync.data.session_diff[id] together and only calls
refetchArtifactHistory() once when the combined guard conditions pass, or add a
short debounce/one-shot guard (e.g., a local lastRefetchSessionID or a timeout)
inside that unified effect to ensure back-to-back triggers for the same session
do not call refetchArtifactHistory() twice. Ensure you reference the existing
symbols createEffect, input.sessionID(), input.turnDiffs(),
input.sync.data.session_diff and refetchArtifactHistory when making the change.

In `@packages/app/src/pages/session/use-session-scroll-dock.test.ts`:
- Around line 109-137: The test "syncs composer height through one path and
scrolls once when sticky" modifies the global document.documentElement.style by
setting the "--composer-dock-height" CSS property, but does not clean up after
itself. Add a cleanup step after the test assertions to remove the
"--composer-dock-height" property using removeProperty method on
document.documentElement.style to prevent this mutation from affecting other
tests. Consider using an afterEach hook if this pattern is repeated across
multiple tests, or add cleanup directly at the end of this test function.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8fe63b45-8370-43a1-992a-755e5a2a0ea0

📥 Commits

Reviewing files that changed from the base of the PR and between 4d6e7e5 and 765de25.

📒 Files selected for processing (16)
  • packages/app/e2e/session/session-composer-dock.spec.ts
  • packages/app/src/components/prompt-input/submit.test.ts
  • packages/app/src/pages/session.tsx
  • packages/app/src/pages/session/message-timeline.tsx
  • packages/app/src/pages/session/use-session-desktop-context.test.ts
  • packages/app/src/pages/session/use-session-desktop-context.ts
  • packages/app/src/pages/session/use-session-followups.test.ts
  • packages/app/src/pages/session/use-session-followups.ts
  • packages/app/src/pages/session/use-session-history-window.test.ts
  • packages/app/src/pages/session/use-session-history-window.ts
  • packages/app/src/pages/session/use-session-revert.test.ts
  • packages/app/src/pages/session/use-session-revert.ts
  • packages/app/src/pages/session/use-session-review-state.test.ts
  • packages/app/src/pages/session/use-session-review-state.ts
  • packages/app/src/pages/session/use-session-scroll-dock.test.ts
  • packages/app/src/pages/session/use-session-scroll-dock.ts

Comment on lines +969 to +988
await dock.open([
{ content: "first scroll dock task", status: "pending" },
{ content: "second scroll dock task", status: "pending" },
{ content: "third scroll dock task", status: "pending" },
{ content: "fourth scroll dock task expands height", status: "pending" },
{ content: "fifth scroll dock task expands height", status: "pending" },
])

const afterUserScroll = await page.evaluate(() => {
const viewport = document.querySelector('[data-component="scroll-viewport"]')
if (!(viewport instanceof HTMLElement)) return null
return {
scrollTop: viewport.scrollTop,
distanceFromBottom: viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop,
}
})

expect(afterUserScroll).not.toBeNull()
expect(afterUserScroll!.scrollTop).toBeLessThan(before)
expect(afterUserScroll!.distanceFromBottom).toBeGreaterThanOrEqual(distanceBeforeExpansion - 40)
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Post-expansion scroll assertions should wait for observable state to settle.

afterUserScroll is captured immediately after dock.open(...), which can race dock resize + scroll compensation and cause flakiness. Poll the metric you assert on before reading final values.

💡 Proposed stabilization
-      const afterUserScroll = await page.evaluate(() => {
-        const viewport = document.querySelector('[data-component="scroll-viewport"]')
-        if (!(viewport instanceof HTMLElement)) return null
-        return {
-          scrollTop: viewport.scrollTop,
-          distanceFromBottom: viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop,
-        }
-      })
-
-      expect(afterUserScroll).not.toBeNull()
-      expect(afterUserScroll!.scrollTop).toBeLessThan(before)
-      expect(afterUserScroll!.distanceFromBottom).toBeGreaterThanOrEqual(distanceBeforeExpansion - 40)
+      let afterUserScroll: { scrollTop: number; distanceFromBottom: number } | null = null
+      await expect
+        .poll(async () => {
+          afterUserScroll = await page.evaluate(() => {
+            const viewport = document.querySelector('[data-component="scroll-viewport"]')
+            if (!(viewport instanceof HTMLElement)) return null
+            return {
+              scrollTop: viewport.scrollTop,
+              distanceFromBottom: viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop,
+            }
+          })
+          return afterUserScroll?.distanceFromBottom ?? -1
+        })
+        .toBeGreaterThanOrEqual(distanceBeforeExpansion - 40)
+
+      expect(afterUserScroll).not.toBeNull()
+      expect(afterUserScroll!.scrollTop).toBeLessThan(before)

As per coding guidelines: Wait on observable state with expect(...), expect.poll(...), or existing helpers instead of assuming work is finished after an action.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app/e2e/session/session-composer-dock.spec.ts` around lines 969 -
988, The test reads `afterUserScroll` immediately after `dock.open(...)`, which
can race with dock resize/scroll; change the direct `page.evaluate` call to poll
until the viewport metrics settle using Playwright's `expect.poll` (or
`expect(...).poll`) to repeatedly evaluate the same function that queries
'[data-component="scroll-viewport"]' and returns `{ scrollTop,
distanceFromBottom }`, and assert/poll for `scrollTop` to be < `before` and
`distanceFromBottom` to be >= `distanceBeforeExpansion - 40` (or poll
`distanceFromBottom` first then assert `scrollTop`) so the test waits for the
dock's resize/scroll compensation before making final assertions on
`afterUserScroll`.

Comment on lines +10 to +18
rolledRevertItems({
revertMessageID: "b",
messages: [message("a"), message("b"), message("c")],
lineText: (id) => `line:${id}`,
}),
).toEqual([
{ id: "b", text: "line:b" },
{ id: "c", text: "line:c" },
])
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Revert slicing is being validated with lexicographically ordered IDs only.

This test locks in behavior that depends on string comparison order (id >= revertMessageID) rather than timeline order. If message IDs are non-ordered strings, revert output can be wrong. Please switch rolledRevertItems to index-based slicing and update this test to use non-lexical IDs.

💡 Proposed fix
diff --git a/packages/app/src/pages/session/use-session-revert.ts b/packages/app/src/pages/session/use-session-revert.ts
@@
 export function rolledRevertItems(input: {
   revertMessageID: string | undefined
   messages: UserMessage[]
   lineText: (id: string) => string
 }) {
   const id = input.revertMessageID
   if (!id) return []
-  return input.messages
-    .filter((item) => item.id >= id)
+  const start = input.messages.findIndex((item) => item.id === id)
+  if (start < 0) return []
+  return input.messages
+    .slice(start)
     .map((item) => ({ id: item.id, text: input.lineText(item.id) }))
 }
diff --git a/packages/app/src/pages/session/use-session-revert.test.ts b/packages/app/src/pages/session/use-session-revert.test.ts
@@
       rolledRevertItems({
-        revertMessageID: "b",
-        messages: [message("a"), message("b"), message("c")],
+        revertMessageID: "msg_2",
+        messages: [message("msg_10"), message("msg_2"), message("msg_30")],
         lineText: (id) => `line:${id}`,
       }),
     ).toEqual([
-      { id: "b", text: "line:b" },
-      { id: "c", text: "line:c" },
+      { id: "msg_2", text: "line:msg_2" },
+      { id: "msg_30", text: "line:msg_30" },
     ])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
rolledRevertItems({
revertMessageID: "b",
messages: [message("a"), message("b"), message("c")],
lineText: (id) => `line:${id}`,
}),
).toEqual([
{ id: "b", text: "line:b" },
{ id: "c", text: "line:c" },
])
rolledRevertItems({
revertMessageID: "msg_2",
messages: [message("msg_10"), message("msg_2"), message("msg_30")],
lineText: (id) => `line:${id}`,
}),
).toEqual([
{ id: "msg_2", text: "line:msg_2" },
{ id: "msg_30", text: "line:msg_30" },
])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app/src/pages/session/use-session-revert.test.ts` around lines 10 -
18, The test and implementation rely on lexicographic comparison of message IDs
causing incorrect slicing; update the rolledRevertItems implementation to find
the index of the message whose id equals revertMessageID and slice the messages
array by index (i.e., include items after that index) instead of using string
comparison (remove any `id >= revertMessageID` style logic in
rolledRevertItems), and update the use-session-revert.test.ts case to use
non-lexical IDs (e.g., "x1","a2","b3" or random GUID-like strings) to assert
index-based behavior matches expected order.

Comment on lines +45 to +47
const [store, setStore] = createStore({
changes: "turn" as ReviewChangeMode,
})
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.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Consider: Single-field store could be createSignal.

The store for changes has only one independent field. Per coding guidelines, createStore is preferred for coupled state updated together. A createSignal<ReviewChangeMode>("turn") would be slightly simpler here. However, this is a minor style preference and the current implementation works correctly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app/src/pages/session/use-session-review-state.ts` around lines 45 -
47, The state uses createStore for a single independent field "changes"; replace
the createStore/createStore-set pattern with a
createSignal<ReviewChangeMode>("turn") to simplify state: remove createStore and
setStore, introduce a signal (e.g., const [changes, setChanges] =
createSignal<ReviewChangeMode>("turn")) and update all usages of store.changes
and setStore to use changes() and setChanges(...) respectively; keep the
ReviewChangeMode type and behavior unchanged.

Comment on lines +179 to +191
createEffect(() => {
const id = input.sessionID()
if (!id) return
input.turnDiffs()
void refetchArtifactHistory()
})

createEffect(() => {
const id = input.sessionID()
if (!id) return
if (input.sync.data.session_diff[id] === undefined) return
void refetchArtifactHistory()
})
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.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Potential redundant refetch on session change.

Two effects can trigger refetchArtifactHistory() on overlapping conditions:

  1. Lines 179-184: When sessionID or turnDiffs changes
  2. Lines 186-191: When session_diff[id] becomes defined

When navigating to a session, both effects may fire close together, causing duplicate fetches. Consider consolidating into one effect or adding a short debounce via a single tracking variable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app/src/pages/session/use-session-review-state.ts` around lines 179
- 191, The two createEffect blocks (watching input.sessionID()/input.turnDiffs()
and input.sync.data.session_diff[id]) can both call refetchArtifactHistory() and
cause duplicate fetches; consolidate them into a single createEffect that reads
input.sessionID(), input.turnDiffs(), and input.sync.data.session_diff[id]
together and only calls refetchArtifactHistory() once when the combined guard
conditions pass, or add a short debounce/one-shot guard (e.g., a local
lastRefetchSessionID or a timeout) inside that unified effect to ensure
back-to-back triggers for the same session do not call refetchArtifactHistory()
twice. Ensure you reference the existing symbols createEffect,
input.sessionID(), input.turnDiffs(), input.sync.data.session_diff and
refetchArtifactHistory when making the change.

Comment on lines +109 to +137
test("syncs composer height through one path and scrolls once when sticky", () => {
const scroller = makeScroller({
clientHeight: 400,
scrollHeight: 1000,
scrollTop: 600,
})
const calls: number[] = []

const next = syncComposerDockHeight({
el: scroller.el,
previousDockHeight: 120,
nextDockHeight: 180,
userScrolled: false,
setCssHeight: (height) => {
document.documentElement.style.setProperty("--composer-dock-height", `${height}px`)
},
forceScrollToBottom: () => {
calls.push(1)
scroller.el.scrollTop = scroller.el.scrollHeight
},
scheduleScrollState: () => undefined,
fill: () => undefined,
})

expect(next).toBe(180)
expect(document.documentElement.style.getPropertyValue("--composer-dock-height")).toBe("180px")
expect(calls).toHaveLength(1)
expect(scroller.top).toBe(1000)
})
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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reset --composer-dock-height after the test to avoid cross-test bleed.

This test mutates global document.documentElement.style and leaves it set, which can affect subsequent cases.

💡 Proposed fix
   test("syncs composer height through one path and scrolls once when sticky", () => {
+    const previousDockHeight = document.documentElement.style.getPropertyValue("--composer-dock-height")
     const scroller = makeScroller({
       clientHeight: 400,
       scrollHeight: 1000,
       scrollTop: 600,
     })
     const calls: number[] = []
-
-    const next = syncComposerDockHeight({
-      el: scroller.el,
-      previousDockHeight: 120,
-      nextDockHeight: 180,
-      userScrolled: false,
-      setCssHeight: (height) => {
-        document.documentElement.style.setProperty("--composer-dock-height", `${height}px`)
-      },
-      forceScrollToBottom: () => {
-        calls.push(1)
-        scroller.el.scrollTop = scroller.el.scrollHeight
-      },
-      scheduleScrollState: () => undefined,
-      fill: () => undefined,
-    })
-
-    expect(next).toBe(180)
-    expect(document.documentElement.style.getPropertyValue("--composer-dock-height")).toBe("180px")
-    expect(calls).toHaveLength(1)
-    expect(scroller.top).toBe(1000)
+    try {
+      const next = syncComposerDockHeight({
+        el: scroller.el,
+        previousDockHeight: 120,
+        nextDockHeight: 180,
+        userScrolled: false,
+        setCssHeight: (height) => {
+          document.documentElement.style.setProperty("--composer-dock-height", `${height}px`)
+        },
+        forceScrollToBottom: () => {
+          calls.push(1)
+          scroller.el.scrollTop = scroller.el.scrollHeight
+        },
+        scheduleScrollState: () => undefined,
+        fill: () => undefined,
+      })
+
+      expect(next).toBe(180)
+      expect(document.documentElement.style.getPropertyValue("--composer-dock-height")).toBe("180px")
+      expect(calls).toHaveLength(1)
+      expect(scroller.top).toBe(1000)
+    } finally {
+      if (previousDockHeight) {
+        document.documentElement.style.setProperty("--composer-dock-height", previousDockHeight)
+      } else {
+        document.documentElement.style.removeProperty("--composer-dock-height")
+      }
+    }
   })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app/src/pages/session/use-session-scroll-dock.test.ts` around lines
109 - 137, The test "syncs composer height through one path and scrolls once
when sticky" modifies the global document.documentElement.style by setting the
"--composer-dock-height" CSS property, but does not clean up after itself. Add a
cleanup step after the test assertions to remove the "--composer-dock-height"
property using removeProperty method on document.documentElement.style to
prevent this mutation from affecting other tests. Consider using an afterEach
hook if this pattern is repeated across multiple tests, or add cleanup directly
at the end of this test function.

@Astro-Han
Copy link
Copy Markdown
Owner Author

Astro-Han commented May 1, 2026

Updated with the follow-up session page split pass.

What changed:

  • session.tsx is now 573 lines, down from 1386 after the first split pass.
  • Extracted comment context, review panel wiring, keyboard focus, refresh effects, active message state, composer region wrapper, main view shell, route tab handoff, VCS refresh, and timeline data.
  • Kept right-panel redesign out of scope; this is still behavior-preserving extraction plus the original composer dock scroll fix.

Verification rerun locally:

  • bun typecheck: passed.
  • Focused unit suite excluding side-panel mock-conflict file: 47 passed, 0 failed.
  • bun test --preload ./happydom.ts src/pages/session/session-side-panel.test.tsx: 9 passed, 0 failed.
  • bun test:e2e:local -- --grep "composer dock keeps latest turn visible": 1 passed.
  • git diff --check: passed.

Note: session-side-panel.test.tsx was run separately because its Bun module mocks conflict with other session tests when batched in the same process.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app Application behavior and product flows P2 Medium priority ui Design system and user interface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Split session page controllers and fix composer dock scrolling

1 participant