Skip to content

fix(web): chat pane preserves scroll position when todo card grows#2299

Merged
mrcfps merged 11 commits into
nexu-io:mainfrom
eefynet:fix/web-chat-autoscroll
May 22, 2026
Merged

fix(web): chat pane preserves scroll position when todo card grows#2299
mrcfps merged 11 commits into
nexu-io:mainfrom
eefynet:fix/web-chat-autoscroll

Conversation

@neogenix
Copy link
Copy Markdown
Contributor

Why

While running a complex session with the agent making frequent TodoWrite
updates, I noticed the chat pane stopped following the latest output
whenever a new task was added to the pinned TodoCard. The chat log
silently drifted off-bottom even though the user had not scrolled.

Tracing it: PinnedTodoSlot renders OUTSIDE the .chat-log scroll
container — it sits as a sibling of .chat-log-wrap inside .pane, NOT
as a child. The existing ResizeObserver in ChatPane.tsx only observed
children of .chat-log, so when the todo card grew, the chat log's
clientHeight shrank in the flex layout and the user drifted away from
the bottom without followLatestIfPinned ever firing.

The existing scroll-pin logic (pinnedToBottomRef, the 80 px cutoff,
the "preserve position if user scrolled up" branch) was already correct.
The ResizeObserver just wasn't seeing the source of the resize.

What users will see

When TodoWrite emits a new snapshot that adds items to the pinned task
card, the chat log auto-scrolls to keep the latest output visible — but
only when the user is already pinned near the bottom (within 80 px). If
the user has scrolled up to read earlier output, their position is
preserved exactly as before. This is the same smart-follow behavior
Discord and terminal apps use; no new setting needed.

Surface area

  • UI — chat pane scroll behavior

No new CLI subcommand needed: this is a pure UI behavior change, not a
new capability. The chat pane already exists; only its auto-scroll
trigger surface was incomplete.

Screenshots

The fix is a behavior change visible only in motion. Reproduction: start
any long agent session that calls TodoWrite multiple times, watch the
chat log, observe that on main the scroll drifts up as the todo card
grows; on this branch the chat log stays pinned to bottom unless the
user has deliberately scrolled up.

Bug fix verification

  • Vitest red spec: apps/web/tests/components/chat-todo-autoscroll.test.tsx
    • Test 1 asserts observedElements contains the .chat-pinned-todo div.
    • Red on main: structural failure (the new ref path doesn't exist).
    • Green on this branch.
  • Playwright real-browser coverage: e2e/ui/chat-todo-autoscroll.test.ts
    • Scenario A: chat log pinned to bottom snaps back after card grows.
      Red on main with distance = 995 px, green here with distance = 0.
    • Scenario B: deliberate scroll-up of ~150 px is NOT overridden by
      subsequent TodoCard growth.

Validation

  • pnpm guard (clean)
  • pnpm typecheck (clean)
  • pnpm --filter @open-design/web test — 9/9 chat-suite tests pass.
    32 unrelated pre-existing failures in useCritiqueTheaterEnabled,
    AssistantMessage, DesignsTab.select-mode, SettingsDialog.execution,
    WorkspaceTabsBar are present on main and unchanged here.
  • pnpm exec playwright test -c playwright.config.ts chat-todo-autoscroll
    — 2 scenarios pass (browser-verified in Chromium).

Adjacent issues (out of scope)

  • The pre-existing smooth-scroll calls in ChatPane.tsx (lines ~423 and
    ~466) ignore prefers-reduced-motion. Not introduced here; deferred
    as a separate a11y pass.
  • apps/web/next.config.ts gains a small OD_WORKSPACE_ROOT env-var
    fallback so Turbopack can resolve apps/web/node_modules when the
    Playwright harness runs from a worktree whose node_modules is a
    symlink pointing outside the worktree root. Default behavior is
    unchanged (the override only fires when the env var is explicitly
    set). CI runs from the main checkout where the default already
    resolves correctly. Included here only because the e2e/ui/
    Playwright spec requires it during local-worktree dev.
  • AGENTS.md "Chat UI conventions" gains one sentence documenting the
    PinnedTodoSlot observer coverage so future implementers don't trip
    over the same out-of-subtree gotcha.

@lefarcen lefarcen requested a review from nettee May 19, 2026 19:39
@lefarcen lefarcen added size/L PR changes 300-700 lines risk/medium Medium risk: regular code changes type/bugfix Bug fix labels May 19, 2026
Copy link
Copy Markdown
Contributor

@nettee nettee left a comment

Choose a reason for hiding this comment

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

Found two non-blocking follow-ups in the new test/config paths.

🔁 Powered by Looper · runner=reviewer · agent=opencode · An autonomous AI dev team for your GitHub repos.

Comment thread apps/web/next.config.ts Outdated
Comment thread e2e/ui/chat-todo-autoscroll.test.ts Outdated
@lefarcen lefarcen requested a review from nettee May 19, 2026 20:21
Copy link
Copy Markdown
Contributor

@nettee nettee left a comment

Choose a reason for hiding this comment

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

Two non-blocking follow-ups remain in the new config and e2e coverage paths.

🔁 Powered by Looper · runner=reviewer · agent=opencode · An autonomous AI dev team for your GitHub repos.

Comment thread apps/web/next.config.ts
Comment thread e2e/ui/chat-todo-autoscroll.test.ts Outdated
Copy link
Copy Markdown
Contributor

@nettee nettee left a comment

Choose a reason for hiding this comment

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

One non-blocking follow-up remains in the new OD_WORKSPACE_ROOT validation path.

🔁 Powered by Looper · runner=reviewer · agent=opencode · An autonomous AI dev team for your GitHub repos.

Comment thread apps/web/next.config.ts
@lefarcen lefarcen requested a review from nettee May 19, 2026 20:48
Copy link
Copy Markdown
Contributor

@nettee nettee left a comment

Choose a reason for hiding this comment

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

Two non-blocking follow-ups remain in the new workspace-root validation and Playwright regression coverage.

🔁 Powered by Looper · runner=reviewer · agent=opencode · An autonomous AI dev team for your GitHub repos.

Comment thread apps/web/next.config.ts Outdated
Comment thread e2e/ui/chat-todo-autoscroll.test.ts Outdated
@neogenix
Copy link
Copy Markdown
Contributor Author

Fixed in 7a1b1c8 — addresses the three remaining @nettee findings:

  1. OD_WORKSPACE_ROOT ancestor-path bypass: apps/web/next.config.ts now also requires pnpm-workspace.yaml to exist at the resolved path. <repo>/apps and <repo>/apps/web were both passing the prior relative(resolved, WEB_ROOT) check; the new check throws at config load with a clear message rather than letting Next fail deep inside Turbopack with a harder-to-diagnose error.

  2. Scenario B false-green branch: dropped the now-dead if (scrollUpOccurred) block. The expect(distanceAfterScroll).toBeGreaterThan(80) precondition above it already guarantees the assertion, so the if was redundant. The body now runs unconditionally — no skip path.

  3. toBeGreaterThan(60) too loose: replaced with Math.abs(distanceAfterGrow - distanceAfterScroll) < 20. The previous absolute threshold would have passed for a regression like before=150, after=61; the new delta check enforces the comment's actual claim ("within ~20px of where the user left it").

pnpm guard + workspace typechecks all clean.

@neogenix neogenix requested a review from nettee May 19, 2026 21:41
Copy link
Copy Markdown
Contributor

@nettee nettee left a comment

Choose a reason for hiding this comment

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

One non-blocking follow-up remains in the new OD_WORKSPACE_ROOT validation path.

🔁 Powered by Looper · runner=reviewer · agent=opencode · An autonomous AI dev team for your GitHub repos.

Comment thread apps/web/next.config.ts Outdated
@neogenix neogenix force-pushed the fix/web-chat-autoscroll branch from 7a1b1c8 to ddad708 Compare May 20, 2026 13:47
Copy link
Copy Markdown
Contributor

@nettee nettee left a comment

Choose a reason for hiding this comment

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

Two blocking issues remain in the new workspace-root validation and Playwright regression coverage.

🔁 Powered by Looper · runner=reviewer · agent=opencode · An autonomous AI dev team for your GitHub repos.

Comment thread apps/web/next.config.ts Outdated
Comment thread e2e/ui/chat-todo-autoscroll.test.ts
Copy link
Copy Markdown
Contributor

@nettee nettee left a comment

Choose a reason for hiding this comment

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

Found two non-blocking follow-ups in the new regression coverage.

🔁 Powered by Looper · runner=reviewer · agent=opencode · An autonomous AI dev team for your GitHub repos.

Comment thread e2e/ui/chat-todo-autoscroll.test.ts
Comment thread apps/web/tests/components/chat-todo-autoscroll.test.tsx
Copy link
Copy Markdown
Contributor

@nettee nettee left a comment

Choose a reason for hiding this comment

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

@neogenix I reviewed the current head's ChatPane observer wiring, the OD_WORKSPACE_ROOT guard, and the new jsdom/Playwright regression coverage. The mount/unmount observer path and both pinned/non-pinned scroll invariants now line up with the implementation, and I don't see any remaining actionable issues in the changed ranges. Thanks for tightening both the behavior fix and the regression coverage here.

🔁 Powered by Looper · runner=reviewer · agent=opencode · An autonomous AI dev team for your GitHub repos.

neogenix and others added 11 commits May 21, 2026 19:57
The PinnedTodoSlot renders outside the chat-log scroll container. When
the todo card grows (new tasks added via TodoWrite), the scroll container's
clientHeight shrinks in the flex layout, drifting the user away from the
bottom. The existing ResizeObserver only observed children of the chat-log
div, so pinned-todo growth was invisible to followLatestIfPinned.

Fix: pass a containerRef to PinnedTodoSlot and observe that element in the
same ResizeObserver. syncPinnedTodo() is called on effect setup and from
the MutationObserver callback so observation stays current as the slot
appears and disappears across TodoWrite snapshots.

Red spec: apps/web/tests/components/chat-todo-autoscroll.test.tsx
…rows

Clarify test comment: the second test confirms followLatestIfPinned
snaps scroll to bottom when fired. The structural guarantee (pinned-todo
element is observed) is separately asserted in test 1, which is the
check that goes red on main without the fix.
…nnedTodoSlot mount detection

The MutationObserver was only watching the .chat-log element. PinnedTodoSlot
(.chat-pinned-todo) is a sibling of .chat-log-wrap inside .pane, outside the
observed subtree. syncPinnedTodo inside the MutationObserver callback was
therefore dead code for mount/unmount transitions of the slot.

Add a second observation on paneEl (el.parentElement?.parentElement) with
childList-only so the MutationObserver fires when PinnedTodoSlot mounts or
unmounts and syncPinnedTodo can register/deregister the element with the
ResizeObserver.
Add Playwright spec that goes red on origin/main and green on this fix
branch. Scenario A asserts that a chat-log pinned to the bottom snaps
back after the PinnedTodoCard grows (the ResizeObserver-on-pinned-todo
path). Scenario B asserts that a deliberate scroll-up is not overridden.

Also allow OD_WORKSPACE_ROOT env override in next.config.ts so Turbopack
resolves node_modules correctly when the web app is booted from a worktree
whose node_modules symlinks resolve outside the default workspace root.
PinnedTodoSlot sits outside the .chat-log scroll container, so the
ResizeObserver and MutationObserver coverage that keeps auto-scroll
working when the todo card grows is non-obvious to future implementers.
Document the invariant in the Chat UI conventions section.
… test branch

Three follow-ups to nettee's review feedback:

1. apps/web/next.config.ts gains a pnpm-workspace.yaml existence check
   after the relative-path validation. Without it, an override like
   '<repo>/apps' or '<repo>/apps/web' passes the relative(resolved, WEB_ROOT)
   check but the resolved path is missing the sibling packages/* directory
   that apps/web imports from (for example @open-design/contracts). Next
   would later fail deep inside file tracing / Turbopack with a much
   harder-to-diagnose error. Now we throw at config load with a clear message.

2. e2e/ui/chat-todo-autoscroll.test.ts drops the redundant
   'if (scrollUpOccurred)' branch. The hard precondition above it already
   guarantees distanceAfterScroll > 80, so the if was dead code that read
   as a false-green path. The body now runs unconditionally.

3. Same test tightens the post-grow assertion. The previous
   toBeGreaterThan(60) would pass even if a regression dragged the log
   most of the way back to the bottom (e.g. before=150, after=61).
   Replaced with Math.abs(distanceAfterGrow - distanceAfterScroll) less than
   SCROLL_PRESERVATION_TOLERANCE_PX (20) — a delta check that actually
   verifies the comment's claim of 'within ~20px of where the user left it'.
…cenario B assertion

- Use realpathSync on both resolved and WEB_ROOT before the ancestor check so
  that symlinked paths (macOS /tmp vs /private/tmp, worktree checkouts) compare
  correctly instead of false-throwing on a physically valid override.
- Add isAbsolute(rel) guard for the Windows cross-drive case where path.relative()
  returns an absolute path instead of a ..-prefixed string.
- Scenario B: replace distance-to-bottom delta assertion with scrollTop preservation
  check. Growing the pinned todo naturally increases distance-to-bottom by ~extraPx
  (clientHeight shrinks while scrollTop is held fixed), so the old Math.abs(after -
  before) < 20 check would fail on correct behavior. asserting scrollTop directly
  catches the real regression: followLatestIfPinned incorrectly snapping a non-pinned
  user back to the bottom.
- Add hard precondition that clientHeight actually changed so the test fails fast
  if the layout stops exercising the non-pinned path.
@neogenix neogenix force-pushed the fix/web-chat-autoscroll branch from ef67f4e to 698e9c5 Compare May 21, 2026 23:58
@github-actions
Copy link
Copy Markdown
Contributor

Visual regression review

Head: 698e9c5 · Base: 3db1e27

Some cases used the nearest available ancestor baseline instead of the exact base SHA.

0 changed · 17 unchanged · 0 missing baseline · 0 failed

Unchanged cases
Case Main PR Diff
visual-avatar-menu
baseline 2 commit(s) behind
main pr diff
visual-design-systems
baseline 2 commit(s) behind
main pr diff
visual-home
baseline 2 commit(s) behind
main pr diff
visual-home-catalog
baseline 2 commit(s) behind
main pr diff
visual-home-context-picker
baseline 2 commit(s) behind
main pr diff
visual-home-plugin-filter
baseline 2 commit(s) behind
main pr diff
visual-integrations
baseline 2 commit(s) behind
main pr diff
visual-integrations-use-everywhere
baseline 2 commit(s) behind
main pr diff
visual-new-project-modal
baseline 2 commit(s) behind
main pr diff
visual-plugin-details
baseline 2 commit(s) behind
main pr diff
visual-plugins
baseline 2 commit(s) behind
main pr diff
visual-projects
baseline 2 commit(s) behind
main pr diff
visual-projects-kanban
baseline 2 commit(s) behind
main pr diff
visual-settings-byok
baseline 2 commit(s) behind
main pr diff
visual-settings-execution
baseline 2 commit(s) behind
main pr diff
visual-tasks
baseline 2 commit(s) behind
main pr diff
visual-topbar-execution-switcher
baseline 2 commit(s) behind
main pr diff

Visual diff is advisory only and does not block merging.

@lefarcen lefarcen requested a review from nettee May 22, 2026 00:05
Copy link
Copy Markdown
Contributor

@nettee nettee left a comment

Choose a reason for hiding this comment

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

@neogenix I reviewed the current head's ChatPane observer wiring, the OD_WORKSPACE_ROOT guard, and the new jsdom/Playwright regression coverage. Together with the current green CI matrix, the changed ranges look consistent and I don't see any remaining actionable issues here. Thanks for tightening both the autoscroll fix and its regression coverage.

🔁 Powered by Looper · runner=reviewer · agent=opencode · An autonomous AI dev team for your GitHub repos.

@mrcfps mrcfps merged commit ce2e7a0 into nexu-io:main May 22, 2026
17 checks passed
@lefarcen
Copy link
Copy Markdown
Contributor

Hey @neogenix! 🎉

This is live on main now. Tracing the ResizeObserver gap to PinnedTodoSlot sitting outside .chat-log's observed subtree — a flex sibling, not a child — is exactly the kind of layout-aware debugging that's easy to miss without a careful read of the DOM hierarchy. Good catch.

The regression coverage earns its weight here. The jsdom unit spec verifying the new ref path in observedElements, and the Playwright delta assertion (Math.abs(distanceAfterGrow - distanceAfterScroll) < 20) in Scenario B, both enforce the actual claim rather than just checking that nothing throws. That's the standard we want for scroll-behavior fixes.

Thanks for working through all the iteration rounds with @nettee and landing this in good shape. ❤️

@neogenix neogenix deleted the fix/web-chat-autoscroll branch May 22, 2026 19:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk/medium Medium risk: regular code changes size/L PR changes 300-700 lines type/bugfix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants