Skip to content

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

Open
neogenix wants to merge 9 commits into
nexu-io:mainfrom
eefynet:fix/web-chat-autoscroll
Open

fix(web): chat pane preserves scroll position when todo card grows#2299
neogenix wants to merge 9 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.

neogenix added 5 commits May 19, 2026 13:11
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.
@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

const WEB_ROOT = dirname(fileURLToPath(import.meta.url));
const WORKSPACE_ROOT = dirname(dirname(WEB_ROOT));
const WORKSPACE_ROOT = process.env.OD_WORKSPACE_ROOT ?? dirname(dirname(WEB_ROOT));
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.

OD_WORKSPACE_ROOT is used verbatim here, unlike the other path overrides in this file. That means an empty string or a relative path will flow straight into outputFileTracingRoot and turbopack.root, which makes the new worktree override fail in opaque ways instead of either resolving predictably or failing fast. Please normalize/validate this env var before using it here (for example: ignore blank values, and either resolve relative paths against WEB_ROOT or throw unless the override is absolute).

🔁 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 Outdated
// If the viewport is shorter than 150 px of content the scroll moved us away;
// if the chat-log has no scrollable room (few messages, large viewport) the
// scroll will be a no-op and the test is a guaranteed pass. Both are fine.
const scrollUpOccurred = distanceAfterScroll > 80;
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.

This branch lets scenario B pass without asserting anything if the synthetic scroll-up does not move the log more than 80px from the bottom. That creates a false-green path where a later layout or seed change stops exercising the regression entirely, but the test still passes. Please make the precondition explicit instead: assert scrollable overflow and distanceAfterScroll > 80 before growPinnedTodo, so the spec fails fast when it no longer reaches the non-pinned path it is supposed to verify.

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

@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

function resolveWorkspaceRoot(): string {
const override = process.env.OD_WORKSPACE_ROOT;
if (override && override.trim()) {
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.

OD_WORKSPACE_ROOT is still treated as valid as soon as it is non-blank, even though this branch introduced it specifically as a config override. If the value points at a missing directory or a directory that does not actually contain apps/web, Next will fail later inside file tracing / Turbopack with a much harder-to-diagnose error instead of failing at config load time. The evidence is in resolveWorkspaceRoot(): the override is trimmed and resolved, but never checked for existence or containment before being assigned to outputFileTracingRoot and turbopack.root. Please validate the resolved path here (for example, stat/realpath it and throw when it does not exist or when relative(resolvedRoot, WEB_ROOT) escapes the root) so this new override follows the repo's fail-fast rule.

🔁 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 Outdated
// scroll will be a no-op and the test is a guaranteed pass. Both are fine.
const scrollUpOccurred = distanceAfterScroll > 80;

if (scrollUpOccurred) {
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.

Scenario B can still go false-green because the actual regression assertion only runs inside if (scrollUpOccurred). If a later layout, viewport, or seed change leaves distanceAfterScroll <= 80, this test now exits cleanly without ever exercising the "user scrolled up, do not yank them back" path it claims to cover. The new precondition check helps only after the branch is entered, so it does not close the original gap. Please make the precondition mandatory instead: assert scrollable overflow first, then fail if the manual scroll does not move the log beyond the 80px threshold (or keep scrolling until it does) before calling growPinnedTodo().

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

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
Comment on lines +33 to +47
if (!existsSync(resolved)) {
console.warn(
`[next.config] OD_WORKSPACE_ROOT="${override}" resolved to "${resolved}" which does not exist; ` +
`falling back to computed default "${computed}".`,
);
return computed;
}
const rel = relative(resolved, WEB_ROOT);
if (rel.startsWith('..')) {
console.warn(
`[next.config] OD_WORKSPACE_ROOT="${override}" resolved to "${resolved}" but WEB_ROOT "${WEB_ROOT}" ` +
`escapes it (relative path "${rel}"); falling back to computed default "${computed}".`,
);
return computed;
}
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.

resolveWorkspaceRoot() now treats an invalid OD_WORKSPACE_ROOT as a warning-and-fallback path instead of failing at config load. That matters because this PR introduced the override specifically to make worktree Playwright runs deterministic; a typo or stale path can silently switch outputFileTracingRoot/turbopack.root back to the computed default and send the run back to the original opaque resolution behavior. The evidence is in these branches: both the missing-directory case and the WEB_ROOT-outside-root case call console.warn(...) and return computed. Please replace those fallbacks with a thrown configuration error (or equivalent hard failure) so an explicitly provided but invalid OD_WORKSPACE_ROOT fails fast with a clear message.

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

@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
Comment on lines +39 to +47
const rel = relative(resolved, WEB_ROOT);
if (rel.startsWith('..')) {
throw new Error(
`OD_WORKSPACE_ROOT="${override}" resolved to "${resolved}" but WEB_ROOT "${WEB_ROOT}" ` +
`is not inside it (relative path "${rel}"). ` +
`The override must be an ancestor of apps/web.`,
);
}
return resolved;
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.

This validation still accepts values that are ancestors of apps/web but are not actually valid workspace roots for the web app. For example, OD_WORKSPACE_ROOT=<repo>/apps or <repo>/apps/web passes this relative(resolved, WEB_ROOT) check, but WORKSPACE_ROOT is then wired into outputFileTracingRoot and turbopack.root below while the app imports workspace packages from packages/* (for example @open-design/contracts in apps/web/src/components/ChatPane.tsx). That means the new override can still bless a broken path and send the worktree/dev run back to incomplete tracing or opaque module-resolution failures. Please validate the override as the actual repository workspace root instead of any ancestor of apps/web—for example require pnpm-workspace.yaml and/or the expected top-level workspace directories at resolved.

🔁 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 Outdated
Comment on lines +352 to +358
const distanceAfterGrow = await chatLogBottomDistance(page);
// The scroll position should be roughly preserved (within 20 px tolerance
// for rounding or minor layout shifts the browser may apply).
expect(
distanceAfterGrow,
`expected scroll position preserved after todo card grew (before=${distanceAfterScroll} after=${distanceAfterGrow})`,
).toBeGreaterThan(60);
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.

This assertion is weaker than the contract the comment describes. The test says scenario B should preserve the user's scroll position within about 20px, but toBeGreaterThan(60) still passes if a regression drags the log most of the way back to the bottom—for example distanceAfterScroll = 150 and distanceAfterGrow = 61. That can false-green the new regression coverage even though the user's position changed materially. Please compare distanceAfterGrow against distanceAfterScroll directly (for example Math.abs(distanceAfterGrow - distanceAfterScroll) <= 20) instead of only checking that it stays somewhere above 60.

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

… 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'.
@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
`Fix the path or unset the variable to use the computed default.`,
);
}
const rel = relative(resolved, WEB_ROOT);
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.

OD_WORKSPACE_ROOT was added here for worktree / symlinked checkouts, but this ancestor check is still purely lexical. On macOS /tmp vs /private/tmp (and the same kind of aliasing through symlinked worktree paths), both strings can name the same physical repo while path.relative() still returns a .. path, so this branch throws even though the override is a valid physical ancestor. The evidence is in the changed lines here: resolved is validated with relative(resolved, WEB_ROOT) and then used for the pnpm-workspace.yaml lookup without canonicalizing either side first. That means the new escape hatch can still reject exactly the non-standard checkout layouts it was introduced to support. Please canonicalize both resolved and WEB_ROOT with realpathSync before the ancestor and workspace-root checks, then validate the canonical paths instead.

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

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.

3 participants