Skip to content

feat(reader): crawl-version tabs in the reader bookmark#953

Open
FagnerMartinsBrack wants to merge 2 commits into
mainfrom
claude/crawl-version-tabs-reader-qrhr45
Open

feat(reader): crawl-version tabs in the reader bookmark#953
FagnerMartinsBrack wants to merge 2 commits into
mainfrom
claude/crawl-version-tabs-reader-qrhr45

Conversation

@FagnerMartinsBrack

Copy link
Copy Markdown
Member

Context

Crawler Events 1 (#871) gave the reader a right-side crawl bookmark showing a single "last crawled at «time»" tab. This change makes it list dated crawl versions of an article on all three reader surfaces (public /view/*, owner /queue/:id/view, admin recrawl) for every visitor, anonymous included.

Before this, no crawl history existed anywhere — every crawl overwrote content/{enc(url)}/content.html and the scalar contentFetchedAt. This introduces a per-version content snapshot in S3 plus a version log the reader can render.

Behaviour

  • A version = a crawl that changed the canonical content (first crawl included). Unchanged revalidations record nothing.
  • Version identity = crawl date/time truncated to the minute (UTC, e.g. 2026-07-10T09:41Z); a second content-changing crawl in the same minute is an idempotent overwrite.
  • The bookmark shows the newest 10, newest first. Newest = canonical → small current badge; the rest are greyed and aria-disabled (not selectable yet).
  • Tab labels are plain date/time (the "Last crawled at" prefix is gone).

Write side (inline at the three promotion sites)

Recording is wired inline at the select / recrawl / refresh promotion handlers rather than as a new event consumer (CanonicalContentChanged over-fires on recrawl and never fires on refresh). No new events/commands/subscriptions, no new infra.

  • Snapshot: CopyObject from the winner tier source articles/{enc}/sources/{tier}.htmlcontent-versions/{enc(url)}/{minuteId}/content.html. Same-minute → same key → idempotent overwrite.
  • Log: capped list attribute crawlVersions (newest-first minute-ids, cap 10) on the existing hutch-articles row. Write = GetItem projection → pure prepend/dedupe/slice → CAS UpdateItem (attribute_not_exists(crawlVersions) OR crawlVersions = :old); a CAS loser throws so SQS redelivers and the idempotent re-run converges.
  • Ordering: snapshot copy → row append → transitionAndPersist. A persist failure retries with the old hash still on the row, so recording re-runs idempotently.
  • No backfill: pre-feature articles have no crawlVersions; the reader uses a read-side contentFetchedAt fallback instead, so "row entry ⇒ snapshot exists" holds for the future serve-a-version feature.

Companion fix (approved in the plan)

Refresh with changed content but an unchanged winner tier now rewrites the canonical object (the refresh-content-extracted-handler gate becomes existingTier !== winnerTier || contentChanged). This fixes a pre-existing stale-canonical bug and keeps version snapshots consistent with what readers see.

Read side

New findArticleCrawlVersions provider contract; the reader merges provider versions with the contentFetchedAt fallback (versions → dated LocalTime[], newest first; else single fallback; else []). ReaderState.lastCrawledAt is replaced by crawlVersions: LocalTime[], threaded through the view / owner-reader / admin-recrawl pages (the iOS chromeless reader keeps omitting the bookmark). The bookmark component just renders the list — first entry current (badge), others --disabled.

Testing

  • pnpm check is green across all 40 checked projects (lint, types, unit/integration/E2E, 100% coverage, unused-css, knip).
  • New unit tests: key/minute-id builders, appendCrawlVersion (prepend/dedupe/cap), record-crawl-version fake-client tests, and the three handlers (records on change, skips on unchanged, refresh rewrites canonical on content change).
  • Extended route tests (/view anonymous + /queue/:id/view owner): pre-feature article → single current tab + badge; seeded 3-version log → ordered keys + aria-disabled on the older two.
  • The crawl-bookmark-visual E2E now seeds a 3-version capsule; all four Linux PNG baselines were regenerated and the capsule/grip geometry assertions pass.

Notes

  • The -chromium-darwin visual baselines could not be regenerated here (Linux-only environment); they will refresh on the next macOS run. Only the -chromium-linux baselines gate CI, and those are updated.
  • ios-readplace is unaffected (no iOS files touched); it has no check target and its Swift codegen (xcodegen) isn't available in the Linux CI container, so it is not part of pnpm check.

🤖 Generated with Claude Code

https://claude.ai/code/session_01B1UAavvi4Casx8YNAibjp6


Generated by Claude Code

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Resolve conflicts. Use your Read tool to read the local file .github/workflows/claude-PR-conflict-fixer.md for instructions. The file is in the checked-out repository.

Context

@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error —— View job


Resolving merge conflicts for PR #953

  • Read conflict-fixer instructions
  • Understand PR intent from commit history
  • Rebase onto origin/main and identify conflicts
  • Resolve conflicts (preserving PR intent)
    • dynamodb-article-store.ts
    • article-reader.test.ts
  • Run pnpm check to verify
  • Push with --force-with-lease

Rebasing surfaced conflicts in 2 files. Investigating now.
• Branch: claude/crawl-version-tabs-reader-qrhr45

@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error —— View job


I'll analyze this and get back to you.

2 similar comments
@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error —— View job


I'll analyze this and get back to you.

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Resolve conflicts. Use your Read tool to read the local file .github/workflows/claude-PR-conflict-fixer.md for instructions. The file is in the checked-out repository.

Context

@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 27m 53s —— View job


Resolved merge conflicts for PR #953

  • Read conflict-fixer instructions
  • Understand PR intent from commit history
  • Rebase onto origin/main and identify conflicts
  • Resolve conflicts (preserving PR intent)
  • Run pnpm check to verify — green
  • Push with --force-with-lease

Rebased claude/crawl-version-tabs-reader-qrhr45 onto origin/main and resolved three conflicts (two textual, one semantic):

1. dynamodb-article-store.ts — both branches added a function in the same spot. Kept both: main's stampUserArticleIfStillSaved (used by markArticleViewed/markSummaryToggled/markReaderViewSucceeded) and the PR's findArticleCrawlVersions (declared in the store type, backed by the articleCrawlVersions table, and returned).

2. article-reader.test.ts — import conflict. Main moved MAX_POLLS out of ./article-reader into @packages/web-shell; the PR added an ArticleCrawlVersion type import and still pulled MAX_POLLS from ./article-reader. Reconciled to main's split (MAX_POLLS@packages/web-shell, initArticleReader./article-reader) plus the PR's new import, re-sorted so Biome's import ordering stays clean.

3. e2e-server.main.ts (semantic conflict — no textual marker) — the PR made crawlVersions a required field on the shared /e2e/seed-crawled-article fixture and updated its own crawl-bookmark-visual test to send it. But main independently added a new E2E test (verify-banner-nav-visual) that uses the same seed endpoint without crawlVersions, so after the merge its seed failed with 400 !== 201. Made the field optional (z.array(z.string()).default([])), matching the PR's own "pre-feature articles have no crawlVersions" backward-compatibility stance — the crawl-bookmark test still opts in, and version-agnostic seeders keep working. Caught only because pnpm check ran the E2E phase.

Verificationpnpm check is green across all 42 projects: hutch 2929 unit + 11 integration + 9 E2E passed (both visual specs), 100% function coverage, lint/knip/unused-css clean. The single feature commit was amended (original author/message preserved) and force-pushed with --force-with-lease.

• Branch: claude/crawl-version-tabs-reader-qrhr45 → now cleanly 1 commit ahead of origin/main

The reader's crawl bookmark now lists dated crawl versions of an article
across all three reader surfaces (public /view, owner reader, admin recrawl)
for every visitor, anonymous included. Each content-changing crawl is
snapshotted into a per-minute S3 folder and appended to a capped crawlVersions
log on the article row; the reader renders the newest 10 newest-first, the
first as the current version (badge) and the rest greyed and aria-disabled.

Write side records a version inline at the three promotion sites (select,
recrawl, refresh) only when the canonical content actually changed: CopyObject
of the winning tier source to content-versions/{enc}/{minute}/content.html,
then a compare-and-swap append of the minute id. Same-minute re-records are
idempotent overwrites.

Also fixes a pre-existing stale-canonical bug: refresh now rewrites the
canonical object when the readable text changed even if the winner tier did
not flip, so version snapshots stay consistent with what readers see.

Read side threads a new findArticleCrawlVersions provider through the reader,
falling back to the single contentFetchedAt instant for pre-feature articles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1UAavvi4Casx8YNAibjp6
@FagnerMartinsBrack FagnerMartinsBrack force-pushed the claude/crawl-version-tabs-reader-qrhr45 branch from d39e841 to 94f0ffb Compare July 10, 2026 15:42
@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Review this PR. Use your Read tool to read the local file .github/workflows/claude-PR-code-reviewer.md for instructions. The file is in the checked-out repository.

Context

@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 7m 37s —— View job


Review — crawl-version tabs in the reader bookmark

Reviewed the write side (record-crawl-version + the three promotion handlers), the read side (article-reader merge, providers, store), the reader components/CSS/HTML, and the E2E/route tests. The design is clean: a pure appendCrawlVersion (prepend/dedupe/cap) behind a CAS, an idempotent minute-keyed S3 snapshot, a read-side fallback to contentFetchedAt, and consistent threading through all three reader surfaces. The refresh companion fix and the E2E-seed crawlVersions optionality (conflict resolution) both look correct. One correctness gap stands out.

High Priority Issues (Must Fix)

None found

Medium Priority Issues

1. Version minute-id is derived from now() on select/recrawl, so a retry can record a duplicate version — contradicting the stated idempotency guarantee

The PR states: "A persist failure retries with the old hash still on the row, so recording re-runs idempotently." That holds only within the same wall-clock minute.

recordCrawlVersion runs before transitionAndPersist, and on these two handlers crawledAt is a fresh now():

  • select-most-complete-content-handler.ts:132const crawledAt = now().toISOString(); (used at :137)
  • recrawl-content-extracted-handler.ts:125const crawledAt = now().toISOString(); (used at :130)

The refresh handler, by contrast, uses the stable event field:

  • refresh-content-extracted-handler.ts:113crawledAt: detail.contentFetchedAt

Failure sequence on select/recrawl (content changed):

  1. writeCanonicalContent ✓ → recordCrawlVersion ✓ (snapshot …/{minute1}/…, log [minute1])
  2. transitionAndPersist throws (DDB throttle, aggregate optimistic-lock conflict, effect-dispatch error) → caught → batchItemFailures → SQS redelivers.
  3. Retry lands in a later minute. The row's canonicalContentHash was never persisted, so previousHash is unchanged ⇒ contentChanged is still truerecordCrawlVersion runs again with minute2 ⇒ a second S3 snapshot object and a second crawlVersions entry for what is a single content-changing crawl.

The recordCrawlVersion CAS itself is a second trigger: a concurrent-recorder CAS loss (record-crawl-version.ts:72-77) also throws → redelivers → fresh now() → duplicate. Because TierContentExtractedEvent/RecrawlContentExtractedEvent carry no timestamp (events.ts:237-241, :310-312), there's currently no stable anchor here the way refresh has contentFetchedAt.

Impact is bounded (an extra greyed tab, capped at 10 — no data loss/crash), which is why this is Medium rather than High. But it does break the documented "version = a crawl that changed the canonical content" invariant (the second phantom version changed nothing relative to the first) and the idempotency claim in the load-bearing crawl pipeline, and it's untested (the happy-path test covers the line, so 100% coverage doesn't catch it).

Suggested fix: thread a stable extracted-at timestamp through TierContentExtractedEvent and RecrawlContentExtractedEvent (stamped by the upstream emitter at extraction time) and pass it as recordCrawlVersion's crawledAt, mirroring refresh. Then a redelivery re-copies to the same minute key and appendCrawlVersion dedupes to a no-op. Fix this →

Low Priority Suggestions

1. Stale comment in e2e-server.main.ts

projects/hutch/src/e2e/e2e-server.main.ts:184-188 still says the seed "renders a stable 'Last crawled at' label", but this PR removed that prefix — labels are now plain date/time (crawl-bookmark.template.html, and the test asserts "10 Jul '26, 09:14"). Update the comment so it doesn't describe removed behaviour.

This PR has issues that should be addressed before merging.

Notes I verified while reviewing (no action needed): find-canonical-content-hash.ts's whole-file c8 ignore matches the established thin-wrapper convention (find-content-source-tier.ts); the minute-id 2026-07-10T09:41Z parses cleanly via new Date(...); renderCrawlBookmark correctly returns "" for empty versions so the iOS chromeless reader and pre-crawl pages render no bookmark; the crawl-bookmark__tab--disabled purge-ignore is warranted (runtime-interpolated modifier); and the bookmark is SSR-only (not re-rendered on poll), same as the prior single-tab behaviour — not a regression.
• Branch: claude/crawl-version-tabs-reader-qrhr45

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

High Priority Issues (Must Fix)

None found

Medium Priority Issues

1. Version minute-id is derived from now() on select/recrawl, so a retry can record a duplicate version — contradicting the stated idempotency guarantee

The PR states: "A persist failure retries with the old hash still on the row, so recording re-runs idempotently." That holds only within the same wall-clock minute.

recordCrawlVersion runs before transitionAndPersist, and on these two handlers crawledAt is a fresh now():

  • select-most-complete-content-handler.ts:132const crawledAt = now().toISOString(); (used at :137)
  • recrawl-content-extracted-handler.ts:125const crawledAt = now().toISOString(); (used at :130)

The refresh handler, by contrast, uses the stable event field:

  • refresh-content-extracted-handler.ts:113crawledAt: detail.contentFetchedAt

Failure sequence on select/recrawl (content changed):

  1. writeCanonicalContent ✓ → recordCrawlVersion ✓ (snapshot …/{minute1}/…, log [minute1])
  2. transitionAndPersist throws (DDB throttle, aggregate optimistic-lock conflict, effect-dispatch error) → caught → batchItemFailures → SQS redelivers.
  3. Retry lands in a later minute. The row's canonicalContentHash was never persisted, so previousHash is unchanged ⇒ contentChanged is still truerecordCrawlVersion runs again with minute2 ⇒ a second S3 snapshot object and a second crawlVersions entry for what is a single content-changing crawl.

The recordCrawlVersion CAS itself is a second trigger: a concurrent-recorder CAS loss (record-crawl-version.ts:72-77) also throws → redelivers → fresh now() → duplicate. Because TierContentExtractedEvent/RecrawlContentExtractedEvent carry no timestamp (events.ts:237-241, :310-312), there's currently no stable anchor here the way refresh has contentFetchedAt.

Impact is bounded (an extra greyed tab, capped at 10 — no data loss/crash), which is why this is Medium rather than High. But it breaks the documented "version = a crawl that changed the canonical content" invariant and the idempotency claim in the load-bearing crawl pipeline, and it's untested (the happy-path test covers the line, so 100% coverage doesn't catch it).

Suggested fix: thread a stable extracted-at timestamp through TierContentExtractedEvent and RecrawlContentExtractedEvent (stamped by the upstream emitter at extraction time) and pass it as recordCrawlVersion's crawledAt, mirroring refresh. Then a redelivery re-copies to the same minute key and appendCrawlVersion dedupes to a no-op.

Low Priority Suggestions

1. Stale comment in e2e-server.main.ts

projects/hutch/src/e2e/e2e-server.main.ts:184-188 still says the seed "renders a stable 'Last crawled at' label", but this PR removed that prefix — labels are now plain date/time. Update the comment so it doesn't describe removed behaviour.

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Fix the review issues. Use your Read tool to read the local file .github/workflows/claude-PR-code-review-auto-apply.md for instructions. The file is in the checked-out repository.

Context

Review to Address

See the review comment above this one for the issues to fix.

@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 32m 8s —— View job


Fixed the review issue — crawl-version idempotency ✅

Pushed bebf54a4 to claude/crawl-version-tabs-reader-qrhr45.

Medium #1 — duplicate crawl versions on SQS retry. recordCrawlVersion runs before transitionAndPersist, and the select/recrawl handlers keyed the version minute-id off now(). When the persist threw (DDB throttle, optimistic-lock conflict, effect-dispatch error), SQS redelivered the record; the retry landed in a later minute with the old hash still on the row (so contentChanged stayed true), minting a second S3 snapshot and a second crawlVersions entry for one content-changing crawl.

Approach (as the review suggested — mirror refresh): thread an extractedAt timestamp through TierContentExtractedEvent + RecrawlContentExtractedEvent, stamped once by each upstream emitter at extraction time, and use it as recordCrawlVersion's crawledAt. A redelivery now re-copies to the same minute key and appendCrawlVersion dedupes to a no-op.

  • Emitters (5 + 2): save-link-command, save-anonymous-link-command, save-link-raw-html, save-link-raw-pdf, comprehensive-crawl (→ Tier), plus recrawl-link-initiated and comprehensive-crawl (→ Recrawl). The two raw-* handlers gained a now dep, wired at their composition roots. comprehensive-crawl reuses its existing contentFetchedAt (the real fetch instant).
  • Consumers: select + recrawl use detail.extractedAt ?? crawledAt.

Why extractedAt is optional (not required like refresh.contentFetchedAt): these are pre-existing events, so this is a migration. Making it required would fail .parse() for messages in flight across the deploy — and, decisively, the select/recrawl DLQ handlers share the same schema and need only url to terminalize a stuck crawl; a required field would break their ability to mark-exhausted, causing the exact stuck-article failure the canaries guard against. In steady state every message carries extractedAt, so redeliveries are fully idempotent; the now() fallback only covers the deploy window (pre-existing behaviour, no regression).

Tests: updated the 6 emitter suites for the new field; added an explicit redelivery-idempotency test to each consumer (handler invoked twice with now() advancing but a stable extractedAt → the same crawledAt both times), and kept the existing no-extractedAt tests as the fallback-branch coverage.

Verification: pnpm check green across all 42 projects (hutch 2929 unit + 11 integration + 9 E2E, save-link 657; 100% function coverage; lint/knip/unused-css clean) — run both locally and again by the pre-commit hook.

Not done (intentionally): the Low-priority stale e2e-server.main.ts comment — the auto-apply instructions scope this to High/Medium only.
• Branch: claude/crawl-version-tabs-reader-qrhr45

…ff a stable extractedAt

The version minute-id was derived from now() in the select and recrawl
handlers, but recordCrawlVersion runs before transitionAndPersist. When the
persist throws (DDB throttle, optimistic-lock conflict, effect-dispatch error),
SQS redelivers the record; the retry lands in a later wall-clock minute with the
old hash still on the row, so contentChanged stays true and a second S3 snapshot
plus a second crawlVersions entry are recorded for a single content-changing
crawl. That broke the "version = a crawl that changed the canonical content"
invariant and the documented idempotency of the recording step.

Thread an extractedAt timestamp through TierContentExtractedEvent and
RecrawlContentExtractedEvent, stamped once by each upstream emitter at extraction
time, and use it as recordCrawlVersion's crawledAt — mirroring how the refresh
handler already keys off detail.contentFetchedAt. A redelivery now re-copies to
the same minute key and appendCrawlVersion dedupes to a no-op.

The field is optional: messages in flight across the deploy that adds it still
parse, and the select/recrawl DLQ handlers (which need only url to terminalize a
stuck crawl) keep working; the handlers fall back to now() when it is absent.

Co-authored-by: Fayner Brack <FagnerMartinsBrack@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Review this PR. Use your Read tool to read the local file .github/workflows/claude-PR-code-reviewer.md for instructions. The file is in the checked-out repository.

Context

@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 7m 39s —— View job


Review — crawl-version tabs in the reader bookmark (commit bebf54a4)

  • Read reviewer instructions
  • Gather PR diff vs origin/main
  • Review the idempotency fix (extractedAt threading)
  • Review write side (record-crawl-version + handlers)
  • Review read side (article-reader merge, providers, store)
  • Review components/CSS/tests
  • Post review

I re-reviewed the full PR with focus on the idempotency fix from the last round (bebf54a4). The fix is correct, complete, and well-tested.

Idempotency fix — verified end to end. extractedAt is added to TierContentExtractedEvent + RecrawlContentExtractedEvent as an optional field (additive → in-flight messages and the shared DLQ handlers still .parse()). All emitters stamp it once: save-link, save-anonymous-link, save-link-raw-html, save-link-raw-pdf, and comprehensive-crawl→Tier; recrawl-link-initiated and comprehensive-crawl→Recrawl. comprehensive-crawl reuses its real fetch instant (contentFetchedAt); the two raw-* handlers gained a now dep wired at their composition roots. Consumers use detail.extractedAt ?? crawledAt (select/recrawl) and the already-stable detail.contentFetchedAt (refresh). A redelivery now re-copies to the same minute key and appendCrawlVersion dedupes to a no-op.

Tests back the claim at both layers. Handler level: each of select/recrawl asserts crawledAt stays [extractedAt, extractedAt] while now() advances 7 min across two invocations; refresh uses contentFetchedAt. Provider level (record-crawl-version.test.ts): same-minute → snapshot re-copies but the log update is skipped. Plus ordering (writeCanonicalContentrecordCrawlVersiontransitionAndPersist) and the stale-canonical companion fix (refresh rewrites canonical + records when readable text changed with an unchanged winner tier).

Rest of the PR is solid. Read-side merge resolveCrawlVersions (versions → fallback → []) covers all three branches; the DynamoDB and in-memory stores mirror each other faithfully; toCrawlVersionMinuteId/toS3ContentVersionKey are thoroughly unit-tested (incl. non-UTC normalization and colon→hyphen); CSS/template follow BEM + aria-disabled + test-attr separation with a justified purge-ignore; the c8 ignore on find-canonical-content-hash.ts matches the established find-content-source-tier.ts thin-wrapper precedent.

High Priority Issues (Must Fix)

None found.

Medium Priority Issues

None found. (The previous round's Medium — duplicate versions on SQS retry — is properly resolved.)

Low Priority Suggestions

1. Stale seed comment in e2e-server.main.ts:184-188. It still says the seed "renders a stable 'Last crawled at' label", but this PR removed that prefix — tab labels are now plain date/time (10 Jul '26, 09:14). Update the comment so it doesn't describe removed behaviour. (Carried over from the prior review; the auto-apply intentionally skipped it as out-of-scope for High/Medium.) Fix this →

No high or medium priority issues — this PR is ready for human review.
• Branch: claude/crawl-version-tabs-reader-qrhr45

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

High Priority Issues (Must Fix)

None found

Medium Priority Issues

None found

The previous round's Medium (duplicate crawl versions on SQS retry) is properly resolved. extractedAt is threaded through TierContentExtractedEvent + RecrawlContentExtractedEvent as an optional, backward-compatible field, stamped once by every emitter (save-link, save-anonymous-link, save-link-raw-html, save-link-raw-pdf, comprehensive-crawl→Tier; recrawl-link-initiated, comprehensive-crawl→Recrawl). Select/recrawl consume it via detail.extractedAt ?? crawledAt; refresh keeps its already-stable detail.contentFetchedAt. Redelivery idempotency is tested at the handler level (stable crawledAt across an advancing now()) and the provider level (same-minute → snapshot re-copy + log dedupe no-op). Read-side merge, store parity, key/minute-id builders, and the reader components/CSS all check out.

Low Priority Suggestions

1. Stale seed comment in e2e-server.main.ts:184-188

The comment still says the seed "renders a stable 'Last crawled at' label", but this PR removed that prefix — crawl-bookmark tab labels are now plain date/time (e.g. 10 Jul '26, 09:14). Update the comment so it no longer describes removed behaviour. (Carried over from the prior review; the auto-apply intentionally skipped it as out-of-scope for High/Medium.)

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants