Skip to content

Scroll longer recent-session lists instead of capping at five - #191

Draft
leoncheng57 wants to merge 2 commits into
mainfrom
feat/longer-recent-sessions
Draft

Scroll longer recent-session lists instead of capping at five#191
leoncheng57 wants to merge 2 commits into
mainfrom
feat/longer-recent-sessions

Conversation

@leoncheng57

Copy link
Copy Markdown
Owner

What changed and why

"Recently opened" and "Recently active" were capped at five rows. Five is too few
once more than one or two projects are in play, and the issue asks for a much
larger bound while keeping the vertical space the section already occupies.

The cap lived in three coordinated places, which is why nobody could raise it
by changing one number — the other two silently put it back:

Where Was Now
client/lib/recentSessions.tsMAX_VISIBLE_RECENT_SESSIONS, applied by the Math.min clamp in visibleLimit() 5 100
server/routes/recents.tsRECENT_SESSION_LIMIT, bounding the merged "recently active" window 5 100
client/lib/api.tsrecentSessions(..., limit = 5), the number actually requested 5 derived from MAX_VISIBLE_RECENT_SESSIONS

Both clamps keep their Math.min shape, so a caller still cannot widen the bound
by asking for more — the two halves are enforced independently and the browser's
number is not trusted by the BFF. The third site is now derived rather than
repeated: api.recentSessions imports the client constant, so the requested
window and the rendered window cannot drift. (That import is safe:
recentSessions.ts only imports a type from api.ts, so the cycle is erased at
build time.)

The section keeps its height by scrolling internally. Each column became
flex flex-col, and the list sits in a
thin-scrollbar max-h-60 min-h-0 flex-1 overflow-y-auto overscroll-contain
container with a ${testId}-scroll testid. min-h-0 is load-bearing: these
columns are grid children of an overflow-hidden grid and flex children of the
column, and both default to min-height: auto, which would let the list push the
section taller instead of scrolling. max-h-60 (240px) is about five 44px rows —
the height this section already occupied back when five was also the hard cap —
with a sliver of the sixth row visible as a scroll affordance. Both columns share
the bound, so the section cannot become lopsided.

Not changed, deliberately:

  • MAX_STORED_RECENT_SESSIONS stays 50. That is localStorage retention for this
    browser's own open-history, a different concern; 50 stored feeding a 100-row
    window is coherent.
  • No new upstream requests. RECENT_SESSION_CONTEXT_LIMIT already fetches 100
    sessions per directory and limit only slices that existing pool, so raising
    the server bound is free. RECENT_DIRECTORY_LIMIT (40) is untouched.
  • No virtualization and no new dependency (AGENTS.md:340).

AGENTS.md gains decision 12a recording the row-budget framing, the two-clamp
rule and the min-h-0 trap.

Verification actually run

All commands run in this worktree, on the assigned ports, with the full e2e suite
under the shared /tmp/custom-dca-opencode-e2e.lock.

Check Result
npm run typecheck clean (client, server, tools projects)
npm test — baseline before editing 557 passed / 47 files
npm test — after 563 passed / 47 files (+6 unit tests)
npm run build clean (client bundle + tsc -p tsconfig.server.json)
CI=1 PORT=3533 MOCK_OPENCODE_PORT=4736 MOCK_PREVIEW_PORT=4737 npm run test:e2e 313 passed, 1 flaky, 1 skipped — 315 tests in 19 files (baseline 308 in 18 files; +7 new)

Notes on the e2e run:

  • The one flaky test is workflows.ui.spec.ts:206 "keeps the picker attachment out of an ordinary send". It passed on retry, is unrelated to recents, and
    touches none of the files in this diff.
  • The one skipped test is screenshots.ui.spec.ts, which self-skips unless the
    screenshot runner supplies a request file.

New coverage:

  • tests/recent-sessions.test.ts — the assertion that hard-coded
    toHaveLength(5) for a 10-session pool now expects all 10, plus two new cases:
    the clamp still refuses Number.MAX_SAFE_INTEGER and a 10 000 limit (returning
    exactly MAX_VISIBLE_RECENT_SESSIONS), non-finite limits still return nothing,
    and twenty sessions render as twenty rows.
  • tests/recent-fanout.test.ts — the route's limit clamp was previously inline
    and untested. It is now recentSessionLimit() (pure, exported, same behaviour)
    with four new cases: the bound is ≥100, it clamps down and never up, negatives
    and fractions and junk are handled without collapsing the panel to zero rows,
    and the maximum stays injectable.
  • tests/e2e/recents-capacity.ui.spec.ts — 7 new browser tests, described below.
  • tests/e2e/mock-opencode.ts — a new /tmp/mock-recents-project fixture with
    twelve flat ses_recents_* sessions. Per AGENTS.md's shared-state ownership
    rule this directory is owned by the new spec file alone; adding the sessions to
    MOCK_DIRECTORY would have moved the exact row counts smoke.ui.spec.ts
    asserts. The new spec also intercepts /api/recent-sessions to filter the pool
    to its own fixtures, because the BFF unions requested directories with the
    shared pin file and a concurrent spec's pin would otherwise add rows.
  • tests/e2e/smoke.ui.spec.tstoHaveCount(5) in "merges recently active
    across projects newest first" now reads toHaveCount(pinnedFixtures.length)
    with a comment saying it is a fixture-pool assertion, not a cap assertion.
    (It did in fact still pass unchanged, because pinRecentsTo filters the
    response to exactly five ids — but leaving a bare 5 there would have
    misdescribed the system. pinRecentsTo itself is untouched and still working.)

The new browser tests assert: both panels render 12 rows (>5); rows are ordered
newest-first and a row past the fifth is really in the DOM; the section stays
shorter than rows × rowHeight while each scroll container overflows; each column
scrolls independently and moving one does not move the other or the page; a
focused row scrolls itself into view inside the container and Enter navigates;
clicking the seventh row navigates to that session; the empty state still renders
inside the scroll wrapper; and at 390px there is no horizontal overflow and rows
keep their 44px minimum height.

Screenshots

/?directory=/tmp/mock-recents-project
/?directory=/tmp/mock-project

Read these two routes together, and note what each does and does not prove.

  • /?directory=/tmp/mock-recents-project is the new fixture project and is the
    capacity proof: "Recently active" holds twelve sessions, the column is clipped
    mid-row with the sixth peeking below the fifth, and the section is still about
    the height it was at five rows.
  • /?directory=/tmp/mock-project is the pre-existing deterministic fixture and
    does not demonstrate the new capacity: that project only has four
    non-archived sessions, so it renders four rows either way. It is included to
    show the unchanged appearance at low row counts, not the new bound.

In both routes "Recently opened" is empty. That panel is driven by
localStorage, which the screenshot runner does not seed, so no captured image
can show the opened column populated. Its capacity is proven by
recents-capacity.ui.spec.ts instead, which seeds the history and asserts twelve
rows there too. A screenshot is not proof of interaction — nothing below the
"Human verification" heading is established by these images.

Captured locally with npm run screenshots:local (pointed at the request file
above) before opening this PR; both routes validated.

Human verification

Run against a real opencode serve with a project that has more than five
sessions, plus at least one second project.

  1. Action: open the Hub with a project selected that has 8+ sessions.
    Expect: "Recently active" lists more than five rows.
    Failure signal: exactly five rows and no more, which means one of the three
    caps was missed — check the BFF response in DevTools before blaming the UI.
  2. Action: in DevTools' Network tab, inspect the
    /api/recent-sessions?limit=… response for that load.
    Expect: limit=100 in the request URL, and a sessions array longer than
    five.
    Expected failure signal: limit=5 in the URL (the client default did not
    move) or a five-element array despite limit=100 (the server clamp did not
    move).
  3. Action: open six or more different sessions one at a time, returning to the
    Hub between each.
    Expect: "Recently opened" grows past five rows, newest first.
    Failure signal: the list stops growing at five, or reorders unpredictably.
  4. Action: compare the vertical height of the whole recents section against
    main (same window, same zoom) — measure from the top border to the bottom
    border.
    Expect: roughly unchanged, ~240px of list per column plus the heading.
    Failure signal: the section is now hundreds of pixels tall and pushes
    "Choose a project" below the fold. That is min-h-0 failing to apply.
  5. Action: scroll inside the "Recently active" column with a trackpad or
    mouse wheel.
    Expect: only that column scrolls; the page behind it does not move, and
    "Recently opened" stays where it was.
    Failure signal: the whole page scrolls instead, or both columns move
    together.
  6. Action: keep scrolling that column to its bottom edge and continue
    scrolling.
    Expect: the page does not start scrolling (overscroll-contain).
    Failure signal: scroll chaining — the page jumps once the column bottoms out.
  7. Action: click a row that is below the fifth (scroll to it first).
    Expect: navigation to that exact session's conversation, in that session's
    own project directory (check the ?directory= in the address bar).
    Failure signal: the wrong session opens, or the URL carries the currently
    selected project instead of the row's own project.
  8. Action: from the top of the Hub, press Tab repeatedly into the recents list
    and keep going past the visible rows.
    Expect: every row is reachable, and the column scrolls the focused row into
    view; the focus ring is never hidden behind the column edge.
    Failure signal: focus disappears into a clipped region, or Tab skips the
    rows below the fold entirely.
  9. Action: with a row focused, press Enter.
    Expect: the same navigation as clicking it.
    Failure signal: nothing happens.
  10. Action: clear localStorage (opencode.recentSessions.v1) and reload.
    Expect: "Recently opened" shows "Open a session to keep it handy here." and
    the section does not collapse or become lopsided next to a populated
    "Recently active".
    Failure signal: a blank column with no message, or the two columns at
    visibly different heights.
  11. Action: narrow the window to 390px (or load on a phone over Tailscale).
    Expect: the columns stack, each still scrolls within its own budget, rows
    stay at least 44px tall, and there is no horizontal scrollbar.
    Failure signal: a horizontal scrollbar, or the stacked section consuming
    most of the screen height.
  12. Action: with a project selected that has fewer than five sessions, reload.
    Expect: the column renders only those rows with no internal scrollbar, and
    looks as it did before this change.
    Failure signal: a scrollbar on a short list, or dead space forcing the
    section taller than its content.

Classification

VERIFIED (executed in this session, output above):

  • Unit suite 557 → 563, all passing; typecheck clean; production build clean.
  • Full Playwright suite green on the assigned ports under the shared lock:
    313 passed / 1 flaky (pre-existing, unrelated) / 1 skipped.
  • Both caps clamp down and not up, proven directly by unit tests on both the
    client helper and the extracted server helper.
  • In a real browser against the real BFF: twelve rows in both panels, section
    height bounded below rows × rowHeight, both containers overflowing, columns
    scrolling independently without moving the page, focus scrolling a row into view
    inside the container, Enter and click both navigating to the correct session,
    the empty state rendering inside the wrapper, and no horizontal overflow at
    390px with a full list.
  • Screenshot capture succeeded and the twelve-row scrollable column is visible in
    the /tmp/mock-recents-project image.

UNVERIFIED (not reachable from this environment):

  • Everything against a real opencode serve with real sessions. All evidence
    here is the deterministic mock; no live agent was contacted.
  • Wheel and touch scrolling, and therefore overscroll-contain chaining
    (human steps 5, 6, 11). The e2e sets scrollTop programmatically, which proves
    the container is a scroller and is independent per column, but not that a
    trackpad gesture behaves correctly or that scroll chaining is suppressed.
  • Tab-order traversal (human step 8). The e2e focuses a row directly and
    asserts the container scrolled; it does not walk the tab sequence, so "every row
    is reachable by Tab" is asserted by inspection only.
  • Height parity with main (human step 4). Asserted here as an absolute bound
    (≤ max-h-60 + heading), not as a before/after diff against main.
  • A genuinely full 100-row list. The largest list exercised anywhere is twelve;
    the 100 bound itself is only proven by the clamp unit tests.
  • Rendering on a physical phone over Tailscale. 390px was emulated in Chromium.
  • prefers-reduced-motion / assistive tech. No screen-reader pass was made on
    the new scroll container.

FAILED: none.

Disposition: partially verified — ready for review, with the browser-gesture

and live-server items above left for a human.

Everything mechanically checkable is green and the change is proven end-to-end
against the mock. What remains genuinely requires a human at a device: real wheel
and touch scrolling, tab traversal by hand, and a live agent server.

Judgement calls

  1. api.recentSessions derives its default instead of hard-coding 100. The
    prompt named three places to change. Leaving three independent literals is what
    created this bug, so the client's two were collapsed into one constant. The
    server keeps its own number — a BFF must not import client code, and it must
    clamp independently of what the browser claims.
  2. The route's inline clamp was extracted to recentSessionLimit(). Unit tests
    run under environment: "node" with no route harness, and there was no existing
    test that mounted recentRoutes. Extracting the pure function was the smallest
    way to get real assertions on the server half of the cap. Behaviour is
    byte-identical, including the "unparseable falls back to the default, not zero"
    branch.
  3. max-h-60 (240px), not max-h-72 (288px) as the project list below uses.
    The brief says keep the section's current height; 240px is ~five 44px rows,
    which is what it occupied before. 288px would have grown it.
  4. A new spec file and a new mock directory rather than extending
    smoke.ui.spec.ts.
    AGENTS.md's shared-state rule: adding sessions to
    MOCK_DIRECTORY would move the exact row counts other specs assert. The new
    spec also filters the recents response to its own fixtures, because the pin file
    is shared across parallel spec files.
  5. smoke.ui.spec.ts:297 was reworded rather than left alone. It still passed
    unchanged — pinRecentsTo constrains the pool to five ids, so the count is five
    either way — but a bare 5 in a recents test now reads as a cap assertion that
    no longer exists.
  6. No rebase onto current main. This branch is cut from 88ac654 as briefed
    and is now 9 commits behind, because sibling agents merged while this ran.
    Rebasing onto a moving target mid-flight would invalidate the verification above
    without a chance to re-run it. The base is stated here so review can decide.
  7. AGENTS.md decision 12a was added. The repository records non-obvious
    decisions there, and "the cap is enforced twice, both with Math.min, and
    min-h-0 is what makes it scroll" is exactly the kind of thing the next person
    re-derives painfully.

Deliberately not done (follow-ups)

  • Interrupted-state or session-status semantics in these lists — PR fix: model cross-process session ownership #48 rewrites
    current/unknown ownership on these exact surfaces and Surface interrupted sessions in session lists #101 owns surfacing
    interrupted sessions.
  • The 60s recents poll (Hub.tsx:38-40) — unrelated to capacity.
  • Any redesign of SessionTreeList or the sub-session disclosure — shared with the
    main session list; a capacity change must not restyle it.
  • Virtualization — 100 rows does not need it and it would add a dependency.
  • Raising MAX_STORED_RECENT_SESSIONS past 50 — different concern, and the brief
    ruled it out.

Closes #44.

"Recently opened" and "Recently active" were capped at five rows, which is
too few to be useful once a few projects are in play. The cap lived in three
coordinated places, so raising any one of them silently did nothing: the
client view helper clamped with Math.min, the BFF clamped the window it
returned, and the client's own request default asked for five to begin with.

Raise the bound to 100 and let each column scroll inside a fixed-height
container, so the section keeps roughly the height it already had. Both
clamps keep their Math.min shape, so neither side can widen the other, and
api.recentSessions now derives its request default from
MAX_VISIBLE_RECENT_SESSIONS rather than repeating the number a third time.

Raising the server bound costs no extra upstream requests: the per-directory
pool is already fetched at RECENT_SESSION_CONTEXT_LIMIT and the limit only
slices that existing pool. MAX_STORED_RECENT_SESSIONS stays at 50 — that is
localStorage retention for this browser's open-history, a different concern.

The columns are grid children of an overflow-hidden grid, so they need
min-h-0; without it min-height: auto lets the list push the section taller
instead of scrolling.
…ssions

# Conflicts:
#	tests/e2e/mock-opencode.ts
@leoncheng57

Copy link
Copy Markdown
Owner Author

Merged origin/main (5d8602a) into this branch to resolve the conflict introduced by PRs #170/#176/#177/#178/#184 landing mid-run.

Conflict resolution: only tests/e2e/mock-opencode.ts conflicted, and it was purely additive — this branch added RECENTS_DIRECTORY_INPUT while main added SESSION_AGENT_DIRECTORY_INPUT. Both constants were kept; AGENTS.md, client/lib/api.ts and tests/e2e/smoke.ui.spec.ts auto-merged.

Re-verification on the merged tree: typecheck clean, npm test 578 passed / 49 files, build clean, full e2e 321 passed / 1 skipped / 0 failed.

Note on an environmental failure, not a code failure: the first post-merge e2e run reported three workspace failures. The cause was a corrupted shared fixture — /tmp/mock-project/.git existed but had no HEAD, and README.md was missing, so /api/workspace/commits returned nothing. tests/e2e/mock-opencode.ts only re-initializes that repository when .git is absent, so a partially-broken fixture never self-heals. After repairing it, all nine workspace tests passed. This is unrelated to this PR and is worth a follow-up: the guard should validate the repository rather than just the directory's existence.

The two flaky specs (runlog.ui.spec.ts:51, workflows.ui.spec.ts:206) passed on retry and touch none of these files.

@github-actions

Copy link
Copy Markdown
Contributor

Interactive PR preview

Open the public simulator

Deployed from 9451bf9. This preview refreshes on every commit and is removed when the pull request closes.

The simulator contains deterministic fixture data only. It has no OpenCode process, AI provider key, GitHub token, repository secret, or access to a contributor filesystem.

@github-actions

Copy link
Copy Markdown
Contributor

PR screenshots

Route Desktop (dark) Mobile (dark)
/?directory=/tmp/mock-recents-project /?directory=/tmp/mock-recents-project desktop
Open full size
/?directory=/tmp/mock-recents-project mobile
Open full size
/?directory=/tmp/mock-project /?directory=/tmp/mock-project desktop
Open full size
/?directory=/tmp/mock-project mobile
Open full size

Source: 9451bf9 | Download artifact

Captured from the production app against deterministic mock OpenCode fixtures only.

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

Labels

priority:medium Important work to plan next

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make the recently opened and recently active list, much longer than five items each, and make them scrollable

1 participant