From 14c11568530f0b264eb07db74f11956a53a75b0d Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:17:07 +1100 Subject: [PATCH 1/2] fix(client): mark unread sticks on a thread reopened within ten minutes Co-Authored-By: Claude Opus 5.5 --- .../client-runtime/src/state/threadVisits.test.ts | 13 +++++++++++++ packages/client-runtime/src/state/threadVisits.ts | 6 +++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/state/threadVisits.test.ts b/packages/client-runtime/src/state/threadVisits.test.ts index adcac7139e82a..9ad579af07066 100644 --- a/packages/client-runtime/src/state/threadVisits.test.ts +++ b/packages/client-runtime/src/state/threadVisits.test.ts @@ -111,6 +111,19 @@ it("keeps a mark-unread on the open thread until new activity lands", () => { expect(t.visits.slice(1)).toEqual([{ threadId: t.ref.threadId, visitedAt: at(30) }]); }); +it("keeps a mark-unread on a read thread reopened within the open dedupe window", () => { + using t = setup("thread-unread-reopened", { + updatedAt: at(10), + lastVisitedAt: at(10), + latestRun: { completedAt: at(9) }, + }); + claimThreadOpen(environmentId, t.ref.threadId, Date.UTC(2026, 0, 1)); + t.mount(); + t.update({ lastVisitedAt: at(8) }); + vi.advanceTimersByTime(VISIT_DISPATCH_THROTTLE_MS); + expect(t.visits).toEqual([]); +}); + it("keeps mark unread sticky after an expired wake is retired", () => { using t = setup("thread-unread-retired-wake", { updatedAt: at(10), diff --git a/packages/client-runtime/src/state/threadVisits.ts b/packages/client-runtime/src/state/threadVisits.ts index c08f47ed01436..1d787c47d3ba9 100644 --- a/packages/client-runtime/src/state/threadVisits.ts +++ b/packages/client-runtime/src/state/threadVisits.ts @@ -114,7 +114,11 @@ export function createThreadVisitTracker(options: ThreadVisitTrackerOptions) { return; } const lastVisitedAtMs = shell.lastVisitedAt ? Date.parse(shell.lastVisitedAt) : NaN; - if (!Number.isNaN(lastVisitedAtMs) && lastVisitedAtMs >= updatedAtMs) return; + if (!Number.isNaN(lastVisitedAtMs) && lastVisitedAtMs >= updatedAtMs) { + // Already seen, so a later mark-unread stays sticky even when no open was sent. + lastDispatchedUpdatedAt = visitedAt; + return; + } if (lastDispatchedUpdatedAt !== null && Date.parse(lastDispatchedUpdatedAt) >= updatedAtMs) return; From d68ab9548a45c23c35c85c08182bfe8c8aa68750 Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:12:39 +1100 Subject: [PATCH 2/2] fix(client): an older shell row no longer lowers the handled visit watermark Co-Authored-By: Claude Opus 5.5 --- packages/client-runtime/src/state/threadVisits.test.ts | 6 ++++++ packages/client-runtime/src/state/threadVisits.ts | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/state/threadVisits.test.ts b/packages/client-runtime/src/state/threadVisits.test.ts index 9ad579af07066..c67ca4fa807c9 100644 --- a/packages/client-runtime/src/state/threadVisits.test.ts +++ b/packages/client-runtime/src/state/threadVisits.test.ts @@ -122,6 +122,12 @@ it("keeps a mark-unread on a read thread reopened within the open dedupe window" t.update({ lastVisitedAt: at(8) }); vi.advanceTimersByTime(VISIT_DISPATCH_THROTTLE_MS); expect(t.visits).toEqual([]); + + // An authoritative snapshot can briefly replace the row with an older one. + t.update({ updatedAt: at(5), lastVisitedAt: at(5), latestRun: { completedAt: at(4) } }); + t.update({ updatedAt: at(10), lastVisitedAt: at(8), latestRun: { completedAt: at(9) } }); + vi.advanceTimersByTime(VISIT_DISPATCH_THROTTLE_MS); + expect(t.visits).toEqual([]); }); it("keeps mark unread sticky after an expired wake is retired", () => { diff --git a/packages/client-runtime/src/state/threadVisits.ts b/packages/client-runtime/src/state/threadVisits.ts index 1d787c47d3ba9..e63c2ad9c0279 100644 --- a/packages/client-runtime/src/state/threadVisits.ts +++ b/packages/client-runtime/src/state/threadVisits.ts @@ -116,7 +116,8 @@ export function createThreadVisitTracker(options: ThreadVisitTrackerOptions) { const lastVisitedAtMs = shell.lastVisitedAt ? Date.parse(shell.lastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAtMs) && lastVisitedAtMs >= updatedAtMs) { // Already seen, so a later mark-unread stays sticky even when no open was sent. - lastDispatchedUpdatedAt = visitedAt; + if (lastDispatchedUpdatedAt === null || Date.parse(lastDispatchedUpdatedAt) < updatedAtMs) + lastDispatchedUpdatedAt = visitedAt; return; } if (lastDispatchedUpdatedAt !== null && Date.parse(lastDispatchedUpdatedAt) >= updatedAtMs)