From 85d1d35edf06ccf1bfbaca00e16d06f21904fa75 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Fri, 11 Sep 2026 15:52:42 -0400 Subject: [PATCH 1/2] spec: scroll snap-back fix plan (amicode#1012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed spec covering three root causes across four candidate surfaces: - Cause A: sessionListDirectories fresh-array churn → arraysEqual utility - Cause B: Show-gate unmount on refetch → .latest pattern - Cause C: md:overflow-hidden gap → unconditional clip Implementation lives in the opencode fork branch fix/scroll-snap-back-1012. All 1151 fork tests + 3235 amicode tests pass. --- ...c-20260911-153000-home-scroll-snap-back.md | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 config/specs/spec-20260911-153000-home-scroll-snap-back.md diff --git a/config/specs/spec-20260911-153000-home-scroll-snap-back.md b/config/specs/spec-20260911-153000-home-scroll-snap-back.md new file mode 100644 index 00000000..d67a7cdf --- /dev/null +++ b/config/specs/spec-20260911-153000-home-scroll-snap-back.md @@ -0,0 +1,190 @@ +--- +type: spec +schema_version: "1" +spec_id: spec-20260911-153000-home-scroll-snap-back +task_type: implement-slice +acceptance: + - scroll_preserved_narrow_pct >= 100 + - scroll_preserved_polling_pct >= 100 + - skeleton_flash_on_refetch_count == 0 + - test_suite_pass_pct == 100 +invariants: + - widget iframes are never remounted by the fix — context/theme updates stay on the bridge + - the overlay is never edited directly; all changes land in the opencode fork + - no fix targets a component that is not rendered in the reporter's layout +baseline: + none_because: "no prior fix attempt for this bug — first investigation" +--- + +# Fix scroll snap-back in the amicode dashboard (issue #1012) + +## Amendment (round 2, blocking finding from adversarial review) + +Round 1 targeted `HomeDesign` in `home.tsx`. The review found that in the +amicode v2 layout (`VITE_OPENCODE_CHANNEL=dev`, always ON for release builds), +the "/" route goes to `NewSessionLanding` (app.tsx:781), which immediately +redirects to a session. **`HomeDesign` is never rendered in the v2 route tree.** + +This revision re-scopes the fix to the surfaces that actually render. + +## Problem + +On WSL2 (linux-x64, v0.3.4), scrolling down in the "Development projects +view" snaps the viewport back to the top. The scroll position is not +preserved — any attempt to scroll downward resets it. + +## Affected surface — ambiguous, four candidates + +In the v2 layout, there is no standalone "home page" at "/". The reporter's +"Development projects view" is one of these surfaces: + +| # | Surface | File | Scroll container | Rendered? | +|---|---------|------|-------------------|-----------| +| S1 | Sessions flyout (titlebar) | `session-header.tsx:980` | `overflow: hidden auto`, max-height 70vh | **yes** — in v2 titlebar | +| S2 | Side-panel "home" tab (widgets) | `session-side-panel.tsx:284` | `overflow-y-auto` on flex-1 div | **yes** — in session side panel | +| S3 | WorkbenchPanel sidebar | `workbench-panel.tsx:55` | `overflow-y-auto` on flex-1 div | **yes** — when sidebar is open | +| S4 | HomeDesign (full dashboard) | `home.tsx:1078` | `overflow-y-auto` with `md:overflow-hidden` parent | **no** — not routed in v2 layout | + +**Recommendation:** ask the reporter for a screen recording to identify the +exact surface. If that's not available, fix the shared patterns across all +four surfaces — the common root cause affects S1–S3 equally. + +## Root causes (shared pattern across all surfaces) + +### A. `sessionListDirectories` creates fresh arrays that cascade refetches + +`sessionListDirectories()` (helpers.ts:69) always returns a new array. Every +consumer that uses the result as a memo or query key triggers unnecessary +downstream updates: + +- **S1** (`session-header.tsx:730`): `activeSessions` memo calls + `sessionListDirectories(ctx.projects.list(), ctx.sync.data?.project ?? [])` + on every reactive tick. Each new array causes the memo to re-run, creating + a new session list, triggering `` to + re-render all rows. + +- **S4** (`home.tsx:293`): `projectDirectories` memo passes the fresh array + as a `queryKey` to `sessionLoad`, triggering a refetch. During refetch, + `isLoading` flips true → session list unmounts → scroll resets. + +**Evidence:** `sameProjectList` (home-projects.ts:126) already exists as a +structural comparator for the project reconcile path. + +### B. `` gates unmount content during async loading transitions + +Multiple surfaces wrap scrollable content in `` gates +that unmount the list on any refetch, not just the initial load: + +- **S1** (`session-header.tsx:1070–1091`): nested `` gates on + `filteredActiveSessions().length > 0`. When `activeSessions` recomputes + (cause A), the list briefly becomes empty → Show unmounts → scroll resets. + +- **S4** (`home.tsx:1354`): `` unmounts the + entire widget grid during resource refetch. + +- **S2** (`session-side-panel.tsx:285–306`): ` 0 && dashboard()}>` — same pattern. + +### C. `home.tsx` responsive overflow gap (S4 only, if reached) + +The parent container has `md:overflow-hidden` — below the Tailwind 768px +breakpoint, no clip is applied. This is S4-only and does not affect S1–S3. + +## Approach + +Three fixes, scoped to the patterns that affect S1–S3 (the rendered surfaces). +S4 fixes are included defensively since the component exists in the codebase. + +### Fix 1 — Structural equality on `sessionListDirectories` consumers + +Add a custom equality check to every memo that wraps `sessionListDirectories`: + +**`home.tsx:293`** (S4): +```diff + const projectDirectories = createMemo(() => + sessionListDirectories(projects(), focusedSync().data.project ?? []), ++ { equals: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]) } + ) +``` + +**`session-header.tsx:730`** (S1): the `activeSessions` memo calls +`sessionListDirectories` inside its body, not as a direct memo output. The +fix here is to extract the directories into their own memo with structural +equality, so the session computation only re-runs when directories actually +change: + +```tsx +const flyoutDirectories = createMemo( + () => { + if (!open()) return [] + const conn = server.current + if (!conn) return [] + const ctx = globalCtx.ensureServerCtx(conn) + if (!ctx) return [] + return sessionListDirectories(ctx.projects.list(), ctx.sync.data?.project ?? []) + }, + { equals: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]) } +) +``` + +Then `activeSessions` reads `flyoutDirectories()` instead of calling +`sessionListDirectories` inline. + +### Fix 2 — Prevent cards unmount on refetch (S2 and S4) + +**`session-side-panel.tsx:285`** (S2) and **`home.tsx:1354`** (S4): replace +binary `` with a visibility approach that keeps the +DOM alive during refetch. Show the skeleton only on initial load +(`!widgetsRaw.latest`); use CSS `visibility: hidden` during refetch. + +SolidJS peer dep is `^1.9.0` — `.latest` is confirmed available. + +### Fix 3 — `home.tsx` overflow-hidden gap (S4 defensive) + +Drop the `md:` prefix so `overflow: hidden` applies at all widths: + +```diff +- class="... m-2 min-h-0 md:overflow-hidden ..." ++ class="... m-2 min-h-0 overflow-hidden ..." +``` + +Defensive — S4 is not routed in v2, but the component exists and the CSS bug +is real. + +## What could go wrong + +1. **Fix 1 false equality:** `sessionListDirectories` could return arrays + with the same strings in a different order. The equality check is + order-sensitive. Mitigation: `sessionListDirectories` is deterministic + (iterates `opened` then `serverProjects` in order), so order stability is + guaranteed by its implementation (helpers.ts:69–89). + +2. **Fix 2 stale data flash:** during refetch, the user sees stale widget + cards instead of a skeleton. This is the intended trade-off — stale + content is better than a scroll reset. + +3. **Fix 3 narrow-width clipping:** if any layout below `md:` relied on the + parent NOT clipping, those views break. Mitigation: the child already has + `overflow-y-auto`, so the parent clip is safe. + +4. **Surface ambiguity:** if the reporter's surface is S3 (WorkbenchPanel), + none of these fixes apply — that component has no loading gates or + `sessionListDirectories` calls. Its `overflow-y-auto` scroll container is + straightforward. A snap-back there would indicate a different root cause + (likely the VS Code webview or a parent layout mutation). + +## Measurement + +| Criterion | How measured | +|---|---| +| `scroll_preserved_narrow_pct` | Manual: open sessions flyout (S1), scroll down, wait 10s. Pass = position held. 5 trials. | +| `scroll_preserved_polling_pct` | Manual: open sessions flyout with a live solve running (polling every 2.5s), scroll down. Pass = position held after 3 polls. | +| `skeleton_flash_on_refetch_count` | Manual: open side panel "home" tab (S2), trigger a refetch. Count skeleton flashes in 3 trials. | +| `test_suite_pass_pct` | `pnpm --filter amicode test` — all pass. | + +## Open question + +**The exact surface has not been confirmed.** The spec fixes the shared +patterns across S1–S4 but cannot guarantee the reporter's surface is among +them. A screen recording from the reporter would resolve this. If the surface +turns out to be S3 (WorkbenchPanel) or the chat timeline virtualizer, a +separate investigation is needed. From 2931e116e3d812f4ba02bb4b602afe5abf513da5 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Fri, 11 Sep 2026 16:21:23 -0400 Subject: [PATCH 2/2] fix(app-bundle): restore upstream_base tag name + handle SHA fallback in materialize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlay promotion at ce1877dd wrote upstream_base as a raw commit SHA (16747470f976...) instead of the tag name (v1.18.29). materialize.mjs builds the upstream tarball URL as /archive/refs/tags/.tar.gz, which only works for actual tag names — a raw SHA returns 404. Two fixes: 1. Restore upstream_base to 'v1.18.29' (the tag name). 2. Make materialize.mjs detect a raw hex SHA and use /archive/.tar.gz (the commit path) instead of /archive/refs/tags/ — so a future accidental SHA write doesn't break CI again. Unblocks the vsix-gate CI job that has been failing on main since ce1877dd. --- packages/app-bundle/manifest.json | 2 +- packages/app-bundle/scripts/materialize.mjs | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 2484d9fc..43aef390 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -4,7 +4,7 @@ "fork_ref": "local/amicode", "fork_tag": "v1.18.29-amicode.30-11-g0667bccd6d", "fork_sha": "6fa55b660a317357ac30e360e694f65aa7410723", - "upstream_base": "16747470f976aca3d362ad730bcd3fe82ecc2c9a", + "upstream_base": "v1.18.29", "upstream_base_sha": "16747470f976aca3d362ad730bcd3fe82ecc2c9a", "promoted_at": "2026-09-11T16:58:54.648Z", "files": { diff --git a/packages/app-bundle/scripts/materialize.mjs b/packages/app-bundle/scripts/materialize.mjs index a07a8438..ba44aa7c 100644 --- a/packages/app-bundle/scripts/materialize.mjs +++ b/packages/app-bundle/scripts/materialize.mjs @@ -42,7 +42,14 @@ async function fetchUpstream() { console.log(`[materialize] upstream ${repo}@${tag} (cached)`); return cacheTree; } - const url = `https://github.com/${repo}/archive/refs/tags/${tag}.tar.gz`; + // GitHub serves tag archives at /archive/refs/tags/.tar.gz but commit- + // SHA archives at /archive/.tar.gz (no refs/tags/ prefix). Detect a raw + // hex SHA so a future sync:apply that accidentally writes a SHA instead of a + // tag name doesn't break CI with a 404 (the ce1877dd regression). + const isCommitSha = /^[0-9a-f]{40,}$/i.test(tag); + const url = isCommitSha + ? `https://github.com/${repo}/archive/${tag}.tar.gz` + : `https://github.com/${repo}/archive/refs/tags/${tag}.tar.gz`; console.log(`[materialize] fetching ${url}`); const r = await fetch(url); if (!r.ok) throw new Error(`upstream fetch failed: HTTP ${r.status}`);