Repository source: docs/plans/thread_continuation_merge/thread_continuation_merge_step4_implementation_summary.md
Step 4: Implementation Summary — Merge Same-Author Reply Chains on Thread Pages
Stage 1 - Mark continuations server-side
Changes:
templates/pages/thread.php: added a$continuationWindowSeconds = 15 * 60named window and a local$isThreadPostContinuationclosure (same author, time delta within the window, neither post agent-authored viaauthor_label === 'reply-agent'); the reply loop now tracks the previous post (root or prior reply) and passesisContinuationinto eachpost_card.phppartial call.templates/partials/post_card.php: addedcontinuationclass anddata-time="HH:MM"to the<article>when flagged, plusdata-author="<author_label>"on every post card.templates/partials/thread_root_card.php: addeddata-author="<author_label>"to the root<article>(root is never itself a continuation, but is the comparison anchor for the first reply).
Verification:
php -lon all three changed files — no syntax errors.Scratch script (
/tmp/.../scratchpad/verify_stage1.php, removed after use) built a temp repo copy with a root post, 6 guest replies 40s–85s apart, and a 7th guest reply ~28 minutes later; rendered/threads/cont-rootvia a realApplicationinstance and inspected the raw HTML: all 6 quick replies gotcontinuation+ correctdata-time="HH:MM", the 28-minute-later reply did not, and the root card gotdata-author="guest"with nocontinuationclass.Standalone unit check of the closure logic confirmed the agent-authored exclusion: a guest reply immediately after an agent-authored post is NOT flagged as a continuation, and two consecutive agent-authored posts are NOT flagged either.
php tests/run.php(full suite): 590 run, typically 584 passed / 6 failed, matching the existing tracked pre-existing baseline. Ran the full suite 5x; the only failure that varied between runs wasWriteApiSmokeTest::testIncrementalApprovalMatchesFreshRebuildForTransitiveApprovalAndScoreRefresh, which failed 2/5 times with this stage's changes present. Investigated: the diff is on the/activity/?view=approvalpage, not the thread page, and is caused by two posts sharing the exact same2026-09-30T05:11:16Ztimestamp getting tie-broken in a different order between the incremental-update DB path and the fresh-full-rebuild DB path — nothing to do with this stage'scontinuation/data-time/data-authorattributes (confirmed absent from the diff). Re-ran the same test 5x with these changes fully reverted (git stash) and it still failed 1/5 times, confirming this is a pre-existing flake in the read-model tie-break logic, not a regression introduced here.
Notes:
No visual change yet — this stage is server-side flagging only; Stage 2 does the CSS merge.
Pre-existing flake
WriteApiSmokeTest::testIncrementalApprovalMatchesFreshRebuildForTransitiveApprovalAndScoreRefresh(same-second timestamp tie-break on the activity page) is out of scope for this feature and was not introduced by this change; left as-is.
Stage 2 - Merge continuation runs visually with CSS
Changes:
public/assets/site.css:.post-card-permalinknow defaults toopacity: 0and reveals on.post-card:hover/:focus-within(applies to every post card, not just runs).public/assets/content-interactions.css: added the continuation-run rules — a card immediately followed by.continuationloses its bottom border/margin (:has(+ .continuation));.continuationitself loses its top margin and gains a faint dashed top separator (color-mix(in srgb, var(--line) 35%, transparent), theme-aware);.continuation > .metais hidden;.continuation::beforeshowsattr(data-time)in the left gutter on hover/focus; every card in a run except the last hides.post-card-actionsuntil hover/focus-within.
Verification:
phpbrace-balance sanity check on both CSS files (equal{/}counts).Seeded a throwaway thread (root + 6 quick guest continuations) into the local dev repo (
state/local_repository, gitignored scratch data), ran./v3 rebuild, served the app locally, and captured headless-Chromium screenshots at desktop width and phone width (390px): the root post and all 6 continuations render as one visually merged card with a single byline and a single action row at the bottom (on the last piece); dashed separators appear between pieces; no repeated headers/action rows. Confirmed viacurlthatdata-time/continuationattributes are present on exactly the 6 intended posts.Attempted an automated dark-theme screenshot (
--blink-settings=preferredColorScheme=2/--force-color-scheme=dark); theme switching in this app is done by client-side JS swapping a<link>to a separatetheme-dark.cssfile (not aprefers-color-schememedia query or[data-theme]selector in the edited stylesheets), so a single-shot headless screenshot didn't exercise it. Confirmed by reading the theme files directly that boththeme-light.cssandtheme-dark.cssredefine--line/--ink-soft— the only custom properties the new rules reference — so the merge rules are theme-agnostic by construction; an interactive dark-theme screenshot (actually switching the theme) is deferred to Stage 4's dedicated cross-theme pass.Cleaned up afterward: killed the temp dev server, hard-reset the scratch commit in
state/local_repository, re-ran./v3 rebuildto restore the original read model (confirmed post/thread counts back to baseline), removed screenshots.php tests/run.php(full suite): 590 run, 584 passed, 6 failed — matches the tracked pre-existing baseline exactly, no new failures.
Notes:
Touch devices have no
:hover; a tap/focus-reachable affordance for the per-run action-row reveal is deferred to Stage 4 (:focus-withinalready keeps it keyboard-reachable since the row isn'tdisplay: none).:has()selector support wasn't independently verified in older browsers — acceptable for this stage since Stage 4 owns the full cross-browser/theme/touch acceptance pass.
Stage 3 - Collapse root card header to one meta line with a continuation-aware reply count
Changes:
templates/pages/thread.php: pre-computes continuation flags for all replies in one pass (before rendering the root card, so the true reply count is known up front), counts replies where the flag is false into$trueReplyCount, and passes it tothread_root_card.php; the reply-render loop now reuses the pre-computed flags array instead of recomputing inline.templates/partials/thread_root_card.php: merged the three separate<p class="meta">lines (byline, labels, agent marker) into one<p class="meta">, joined with " · ", and appended{N} reply/replies(only when$trueReplyCount > 0), matching the existing pluralization convention already used bythread_card.php's board/tag-list reply count. Did not invent a "like" text token in the merged line — no existing per-viewer like-state text exists anywhere else in this codebase to reuse, and the actual Like action remains unchanged in the action row below. Title/body dedup logic (from a prior slice) was untouched.
Verification:
php -lon both changed files — no syntax errors.Reused the Stage 1 scratch script's temp repo (root + 6 quick continuations + 1 reply 28 minutes later) and asserted the root card's single meta line reads
"...· 1 reply"— correctly counting only the true (non-continuation) reply, not the 6 merged pieces.Ran a second scratch check against the existing
parity_minimal_v1fixture'sroot-001thread: its one reply (reply-001, same guest author, 5 minutes after root) is itself a continuation of the root, so the merged meta line correctly shows no reply-count segment (0true replies) while still showing"· Labels: bug, needs-review"for its thread labels — confirms the label-merge path works and that continuation detection is consistently applied to the fixture data already exercised by the existing test suite.php tests/run.php(full suite): 590 run, 584 passed, 6 failed — identical to the tracked pre-existing baseline (including theWriteApiSmokeTesttimestamp-tie-break flake noted in Stage 1, unrelated to this change); confirmed the existingassertStringContains('Labels: bug, needs-review', $thread)assertion (which only checks substring presence) still passes against the merged single-line format.
Notes:
Reintroduces a reply count on the root card that a prior, unrelated slice (
thread_root_card_remove_reply_count) had deliberately removed — this is intentional per Step 2's scope (a continuation-aware count is a materially different feature than the flat count that was removed) and no test was found asserting the count's absence, so nothing needed to be reverted.Board/tag list reply counts (
thread_card.php) remain untouched, per Step 2 scope.
Stage 4 - Cross-theme, responsive, and touch pass
Changes:
Theme audit found 3 of 13 theme files whose theme-scoped selectors had higher CSS specificity than Stage 2's rules and would have silently overridden them:
theme-arena.cssandtheme-word97.cssboth redeclare.card { padding: ... }and.stack > * + * { margin-top: ... }at:root[data-theme="X"] .stack > * + *specificity, which beats a bare.post-card.continuation; added matching:root[data-theme="arena"/"word97"] .post-card.continuation(margin-top: 0, padding-top override) and.post-card:has(+ .continuation)(padding-bottom override) rules to each.theme-sticker.cssdraws its card boundary withoutline(notborder), which can't be removed on one side only, so a fully seamless single-card illusion isn't achievable there; added a smaller (0.35remvs1.5rem) continuation margin so pieces still sit tightly stacked, and documented the outline limitation as a known, theme-specific tradeoff rather than a bug. The other 10 themes had no conflicting overrides.content-interactions.css: added a@media (max-width: 640px)override repositioning.continuation::before's hover-timestamp from the-3.2remleft gutter (which doesn't exist at phone width —.shellonly has1remof side padding there) to a small inline badge anchored at the card's own top-left corner.Added a touch-reachable affordance for the per-run hidden action row: a small always-focusable
<button class="post-card-actions-toggle">Actions</button>inserted as a sibling immediately before.post-card-actionsin bothpost_card.phpandthread_root_card.php. It's hidden by default and only shown inside@media (hover: none)(touch-primary devices), so hover-capable desktop/mouse users see no change from Stage 2. Tapping it focuses it, which satisfies the existing.post-card:has(+ .continuation):focus-within > .post-card-actionsrule from Stage 2 — no new reveal logic needed, and no JS was added (the "no client-side JS" constraint from Step 3 was specifically about the continuation flag computation, which stays server-side; this is a plain focusable button with no script behavior of its own).public/assets/site.css: no changes this stage (audited, no conflicts found there beyond what Stage 2 already handled).
Verification:
php -lon both changed templates; brace-balance check on all 4 changed CSS files — all clean.Re-seeded the Stage 2 throwaway thread (root + 6 quick continuations) into the local dev repo, rebuilt the read model, served the app locally, and used headless Chromium to check: desktop width (900px) shows the merged card with no visible "Actions" toggles (correctly gated behind
(hover: none)in this environment's default emulation — see caveat below); phone width (390px) renders correctly with no horizontal overflow from the repositioned gutter timestamp; navigating directly to#post-verify-cont-3(a mid-run continuation) auto-scrolled to and highlighted that exact piece with the expected:targetborder-color change, confirming the deep-link/highlight requirement still holds on a merged piece.Caveat: this headless Chromium environment reports
(hover: none)as true by default regardless of window size (no real pointing device attached to the virtual display), so the toggle button rendered as always-visible in every screenshot taken here, including "desktop-width" ones — this is a property of the test environment, not the CSS:(hover: none)/(hover: hover)is a long-standing, universally-supported media feature, and a real desktop browser with an actual mouse will correctly reporthover: hoverand never show the toggle. Could not get a real mouse-vs-touch differential screenshot in this sandbox; flagged rather than silently assumed.Cleaned up afterward: killed the temp dev server, hard-reset the scratch commit in
state/local_repository, re-ran./v3 rebuildto restore the original read model, removed screenshots and cookie jar.php tests/run.php(full suite): 590 run, 583–584 passed depending on the pre-existingWriteApiSmokeTestflake (see Stage 1 notes) — no new failures beyond the tracked pre-existing baseline.
Notes:
The sticker theme's outline-based card boundary is a genuine, theme-specific visual limitation (pieces stay individually outlined rather than merging into one seamless card); acceptable given it's consistent with that theme's overall "everything gets a hard outline" aesthetic, and out of scope to redesign here.
This completes the acceptance-check items from
improve_thread_view.txt: merged card with one byline/action row, hover-revealed time/actions per piece, separate agent-reply card, decluttered single-line header, working deep links — verified directly against the example thread shape (root + 6 quick same-author replies + trailing agent-style gap reply) across light theme, phone width, and (by code inspection, per Stage 2/3 notes) dark/other themes via the same CSS custom properties.
Stage 5 - Automated test coverage
Changes:
tests/LocalAppSmokeTest.php: addedtestThreadMergesSameAuthorQuickRepliesIntoContinuations(). Builds a temp copy of theparity_minimal_v1fixture with a root post and 4 replies: two quick guest replies 5 and 7 minutes apart (both should merge as continuations), then two further guest replies each separated by a >15-minute gap from the previous post (both should count as true replies, not continuations). Asserts: the two quick replies get thecontinuationclass and the correctdata-time="HH:MM"; the two gapped replies do not getcontinuation; the root card and first continuation both carrydata-author="guest"; both continuations' permalinks (<a class="post-card-permalink" href="/posts/...">) are still present and point at their own post ids; the root card's single merged meta line contains"2 replies"(the true count, excluding the 2 continuations).
Verification:
php -l tests/LocalAppSmokeTest.php— no syntax errors.Ran the new test in isolation (
php tests/run.php LocalAppSmokeTest::testThreadMergesSameAuthorQuickRepliesIntoContinuations) — passes.php tests/run.php(full suite): 591 run (590 + this new test), 585 passed, 6 failed — same 5 long-tracked pre-existing failures plus the already-knownWriteApiSmokeTesttimestamp-tie-break flake from Stage 1 (this particular run happened to land on the "passing" side of that flake, hence "Newly recovered" in the run's own diff-against-last-run bookkeeping — not related to this stage's change). No new failures introduced.
Notes:
Did not attempt to fixture an agent-authored post or a distinct-named second author at the full-application level: an agent identity requires standing up
AgentIdentityServicebootstrap machinery, and this fixture repo's one pre-existing bootstrapped identity (openpgp:0168ff20eb09c3ea6193bd3c92a73aa7d20a0954) turned out to resolve to the display label"guest"too (its profile has no claimed username), so it wouldn't actually exercise a "different author" case. Both branches (agent-authored exclusion, and the basic same-vs-different-author string comparison) were already verified directly against theisThreadPostContinuationclosure logic via a standalone unit-style check during Stage 1 and are simple, low-risk boolean branches; adding fixture machinery just to re-prove them at the full-HTTP-render level wasn't judged worth the added complexity.