From 12accb10d10c4a2670168899828f3ea513172090 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:34:43 -0400 Subject: [PATCH] Sync inbox lifecycle state across devices The sidebar's Active/Wrapped split lived in each browser's localStorage, so a phone opened through the relay disagreed with the desktop about which threads were filed away and which completions were unread. Both pieces now live on the thread, on the server: - thread.done-override.set -> thread.done-override-set, projected to done_override / done_override_at - thread.seen.set -> thread.seen-set, projected to last_seen_at Neither event touches the thread's updatedAt. The inbox weighs these stamps against real work activity, and auto-wrap idles off the same value, so filing or reading a thread must not read as activity. The decider stores the client's stamp verbatim for the same reason, and seen is last-write-wins because mark-unread deliberately walks the stamp backwards. The web store keeps both only as in-memory optimistic overlays: a write shows instantly, then retires once the server value moves off the baseline it was made against, and a rejected dispatch drops it. On first connect each environment hands its pre-sync localStorage state to the server once, filling only gaps, then clears those keys. Also names the generated WS RPC client type rather than inferring it: the inferred form inlines every command payload and two more tipped it past the type serialization limit. --- .../Layers/OrchestrationEngine.test.ts | 81 +++++ .../Layers/ProjectionPipeline.ts | 34 ++ .../Layers/ProjectionSnapshotQuery.test.ts | 4 + .../Layers/ProjectionSnapshotQuery.ts | 34 ++ .../Layers/ThreadAutoArchiveSweeper.test.ts | 2 + apps/server/src/orchestration/Schemas.ts | 4 + .../orchestration/commandInvariants.test.ts | 4 + .../decider.diffStatRebase.test.ts | 2 + .../orchestration/decider.followUp.test.ts | 2 + .../decider.generalChats.test.ts | 2 + .../decider.inboxLifecycle.test.ts | 121 +++++++ .../decider.proposedPlan.test.ts | 2 + apps/server/src/orchestration/decider.ts | 50 +++ .../orchestration/decider.turnRetry.test.ts | 2 + .../src/orchestration/projector.test.ts | 90 +++++ apps/server/src/orchestration/projector.ts | 31 ++ .../persistence/Layers/ProjectionThreads.ts | 15 + apps/server/src/persistence/Migrations.ts | 2 + .../042_ProjectionThreadsInboxLifecycle.ts | 32 ++ .../persistence/Services/ProjectionThreads.ts | 10 + .../Layers/ProviderSessionReaper.test.ts | 2 + .../ProviderReviewCoordinator.test.ts | 2 + apps/server/src/server.test.ts | 6 + apps/web/src/components/ChatView.browser.tsx | 39 +- .../web/src/components/ChatView.logic.test.ts | 16 + apps/web/src/components/ChatView.logic.ts | 4 + apps/web/src/components/ChatView.tsx | 11 +- .../components/CommandPalette.logic.test.ts | 2 + .../components/KeybindingsToast.browser.tsx | 4 + apps/web/src/components/Sidebar.logic.test.ts | 50 +++ apps/web/src/components/Sidebar.logic.ts | 31 ++ apps/web/src/components/Sidebar.tsx | 69 ++-- .../src/components/ThreadStatusIndicators.tsx | 5 +- .../settings/ExtensionsSettings.logic.test.ts | 6 +- .../settings/ExtensionsSettings.logic.ts | 6 +- .../settings/ExtensionsSettings.tsx | 6 +- .../sidebar/ThreadHoverCard.browser.tsx | 2 + apps/web/src/environmentGrouping.test.ts | 2 + .../service.threadSubscriptions.test.ts | 2 + apps/web/src/environments/runtime/service.ts | 21 +- apps/web/src/lib/providerReview.test.ts | 2 + apps/web/src/lib/threadInboxSync.ts | 263 ++++++++++++++ apps/web/src/lib/threadSort.test.ts | 12 + apps/web/src/rpc/protocol.ts | 19 +- apps/web/src/store.test.ts | 8 + apps/web/src/store.ts | 39 ++ apps/web/src/threadAutoArchive.test.ts | 2 + apps/web/src/types.ts | 16 + apps/web/src/uiStateStore.test.ts | 102 +++--- apps/web/src/uiStateStore.ts | 339 +++++++++++++----- apps/web/src/worktreeCleanup.test.ts | 2 + packages/contracts/src/orchestration.ts | 90 +++++ 52 files changed, 1507 insertions(+), 197 deletions(-) create mode 100644 apps/server/src/orchestration/decider.inboxLifecycle.test.ts create mode 100644 apps/server/src/persistence/Migrations/042_ProjectionThreadsInboxLifecycle.ts create mode 100644 apps/web/src/lib/threadInboxSync.ts diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index ae4f489e..12aa0914 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -150,6 +150,8 @@ describe("OrchestrationEngine", () => { updatedAt: "2026-03-03T00:00:03.000Z", archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, deletedAt: null, messages: [], proposedPlans: [], @@ -485,6 +487,85 @@ describe("OrchestrationEngine", () => { await system.dispose(); }); + it("keeps inbox lifecycle state on the thread so every device reads the same answer", async () => { + const system = await createOrchestrationSystem(); + const { engine } = system; + const createdAt = now(); + + await system.run( + engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-project-inbox-create"), + projectId: asProjectId("project-inbox"), + title: "Project Inbox", + workspaceRoot: "/tmp/project-inbox", + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt, + }), + ); + await system.run( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-inbox-create"), + threadId: ThreadId.make("thread-inbox"), + projectId: asProjectId("project-inbox"), + title: "File me", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }), + ); + const readThread = async () => + (await system.readModel()).threads.find((thread) => thread.id === "thread-inbox"); + const updatedAtAfterCreate = (await readThread())?.updatedAt; + + await system.run( + engine.dispatch({ + type: "thread.done-override.set", + commandId: CommandId.make("cmd-thread-inbox-done"), + threadId: ThreadId.make("thread-inbox"), + state: "done", + at: "2026-01-02T09:30:00.000Z", + }), + ); + await system.run( + engine.dispatch({ + type: "thread.seen.set", + commandId: CommandId.make("cmd-thread-inbox-seen"), + threadId: ThreadId.make("thread-inbox"), + at: "2026-01-02T09:31:00.000Z", + }), + ); + // Mark unread walks the stamp back to just before the completion it + // un-sees, so a later write with an earlier stamp has to win. + await system.run( + engine.dispatch({ + type: "thread.seen.set", + commandId: CommandId.make("cmd-thread-inbox-unread"), + threadId: ThreadId.make("thread-inbox"), + at: "2026-01-02T09:29:59.999Z", + }), + ); + + const thread = await readThread(); + expect(thread?.doneOverride).toEqual({ state: "done", at: "2026-01-02T09:30:00.000Z" }); + expect(thread?.lastSeenAt).toBe("2026-01-02T09:29:59.999Z"); + // Filing and reading are not work: `updatedAt` is what the inbox weighs + // these stamps against, and what auto-wrap idles off. + expect(thread?.updatedAt).toBe(updatedAtAfterCreate); + + await system.dispose(); + }); + it("replays append-only events from sequence", async () => { const system = await createOrchestrationSystem(); const { engine } = system; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index ab9be68e..8b58f669 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -565,6 +565,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti updatedAt: event.payload.updatedAt, archivedAt: null, pinnedAt: null, + doneOverride: null, + doneOverrideAt: null, + lastSeenAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -633,6 +636,37 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + // Inbox filing and read state deliberately leave `updatedAt` alone -- + // the client weighs both stamps against the thread's real activity. + case "thread.done-override-set": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + doneOverride: event.payload.state, + doneOverrideAt: event.payload.at, + }); + return; + } + + case "thread.seen-set": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + lastSeenAt: event.payload.at, + }); + return; + } + case "thread.meta-updated": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 97a23faa..ab5deacc 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -315,6 +315,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { updatedAt: "2026-02-24T00:00:03.000Z", archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, deletedAt: null, messages: [ { @@ -435,6 +437,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { updatedAt: "2026-02-24T00:00:03.000Z", archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, session: { threadId: ThreadId.make("thread-1"), status: "running", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 162cfd72..338739bc 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -21,6 +21,7 @@ import { type OrchestrationSession, type OrchestrationThreadActivity, type OrchestrationThreadDiffStat, + type OrchestrationThreadDoneOverride, type OrchestrationThreadShell, ModelSelection, OrchestrationThreadGoal, @@ -231,6 +232,15 @@ function mapLatestTurn( }; } +function mapThreadDoneOverride( + row: Schema.Schema.Type, +): OrchestrationThreadDoneOverride | null { + if (row.doneOverride == null || row.doneOverrideAt == null) { + return null; + } + return { state: row.doneOverride, at: row.doneOverrideAt }; +} + function mapThreadDiffStat( row: Schema.Schema.Type | undefined, ): OrchestrationThreadDiffStat | null { @@ -392,6 +402,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updated_at AS "updatedAt", archived_at AS "archivedAt", pinned_at AS "pinnedAt", + done_override AS "doneOverride", + done_override_at AS "doneOverrideAt", + last_seen_at AS "lastSeenAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -425,6 +438,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updated_at AS "updatedAt", archived_at AS "archivedAt", pinned_at AS "pinnedAt", + done_override AS "doneOverride", + done_override_at AS "doneOverrideAt", + last_seen_at AS "lastSeenAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -460,6 +476,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updated_at AS "updatedAt", archived_at AS "archivedAt", pinned_at AS "pinnedAt", + done_override AS "doneOverride", + done_override_at AS "doneOverrideAt", + last_seen_at AS "lastSeenAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -977,6 +996,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updated_at AS "updatedAt", archived_at AS "archivedAt", pinned_at AS "pinnedAt", + done_override AS "doneOverride", + done_override_at AS "doneOverrideAt", + last_seen_at AS "lastSeenAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -1423,6 +1445,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updatedAt: row.updatedAt, archivedAt: row.archivedAt, pinnedAt: row.pinnedAt, + doneOverride: mapThreadDoneOverride(row), + lastSeenAt: row.lastSeenAt ?? null, deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1660,6 +1684,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updatedAt: row.updatedAt, archivedAt: row.archivedAt, pinnedAt: row.pinnedAt, + doneOverride: mapThreadDoneOverride(row), + lastSeenAt: row.lastSeenAt ?? null, deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1806,6 +1832,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updatedAt: row.updatedAt, archivedAt: row.archivedAt, pinnedAt: row.pinnedAt, + doneOverride: mapThreadDoneOverride(row), + lastSeenAt: row.lastSeenAt ?? null, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1955,6 +1983,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updatedAt: row.updatedAt, archivedAt: row.archivedAt, pinnedAt: row.pinnedAt, + doneOverride: mapThreadDoneOverride(row), + lastSeenAt: row.lastSeenAt ?? null, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -2221,6 +2251,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, pinnedAt: threadRow.value.pinnedAt, + doneOverride: mapThreadDoneOverride(threadRow.value), + lastSeenAt: threadRow.value.lastSeenAt ?? null, session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, @@ -2321,6 +2353,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, pinnedAt: threadRow.value.pinnedAt, + doneOverride: mapThreadDoneOverride(threadRow.value), + lastSeenAt: threadRow.value.lastSeenAt ?? null, deletedAt: null, messages: messageRows.map(mapThreadMessageRow), proposedPlans: proposedPlanRows.map(mapProposedPlanRow), diff --git a/apps/server/src/orchestration/Layers/ThreadAutoArchiveSweeper.test.ts b/apps/server/src/orchestration/Layers/ThreadAutoArchiveSweeper.test.ts index 13048e6b..3f638868 100644 --- a/apps/server/src/orchestration/Layers/ThreadAutoArchiveSweeper.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadAutoArchiveSweeper.test.ts @@ -64,6 +64,8 @@ function thread( updatedAt: daysAgo(45), archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, session: null, latestUserMessageAt: daysAgo(45), hasPendingApprovals: false, diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index c934c48d..df046911 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -11,6 +11,8 @@ import { ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema, ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema, + ThreadDoneOverrideSetPayload as ContractsThreadDoneOverrideSetPayloadSchema, + ThreadSeenSetPayload as ContractsThreadSeenSetPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -44,6 +46,8 @@ export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSet export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema; +export const ThreadDoneOverrideSetPayload = ContractsThreadDoneOverrideSetPayloadSchema; +export const ThreadSeenSetPayload = ContractsThreadSeenSetPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index be7eff75..90abf1e4 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -73,6 +73,8 @@ const readModel: OrchestrationReadModel = { updatedAt: now, archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, latestTurn: null, messages: [], session: null, @@ -100,6 +102,8 @@ const readModel: OrchestrationReadModel = { updatedAt: now, archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, latestTurn: null, messages: [], session: null, diff --git a/apps/server/src/orchestration/decider.diffStatRebase.test.ts b/apps/server/src/orchestration/decider.diffStatRebase.test.ts index 24f4aaa8..39755d25 100644 --- a/apps/server/src/orchestration/decider.diffStatRebase.test.ts +++ b/apps/server/src/orchestration/decider.diffStatRebase.test.ts @@ -40,6 +40,8 @@ function makeReadModel(diffStatBaselineTurnCount: number): OrchestrationReadMode updatedAt: now, archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/apps/server/src/orchestration/decider.followUp.test.ts b/apps/server/src/orchestration/decider.followUp.test.ts index cec38fea..e71e8631 100644 --- a/apps/server/src/orchestration/decider.followUp.test.ts +++ b/apps/server/src/orchestration/decider.followUp.test.ts @@ -47,6 +47,8 @@ const readModelAfterProviderDelivery: OrchestrationReadModel = { updatedAt: now, archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/apps/server/src/orchestration/decider.generalChats.test.ts b/apps/server/src/orchestration/decider.generalChats.test.ts index 96653189..79ea4f8a 100644 --- a/apps/server/src/orchestration/decider.generalChats.test.ts +++ b/apps/server/src/orchestration/decider.generalChats.test.ts @@ -260,6 +260,8 @@ function makeThread(input: { updatedAt: now, archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, deletedAt: null, messages: [ { diff --git a/apps/server/src/orchestration/decider.inboxLifecycle.test.ts b/apps/server/src/orchestration/decider.inboxLifecycle.test.ts new file mode 100644 index 00000000..1b554c82 --- /dev/null +++ b/apps/server/src/orchestration/decider.inboxLifecycle.test.ts @@ -0,0 +1,121 @@ +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, +} from "@threadlines/contracts"; +import * as Effect from "effect/Effect"; +import { describe, expect, it } from "vite-plus/test"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const now = "2026-01-01T00:00:00.000Z"; +const threadId = ThreadId.make("thread-inbox"); + +function makeReadModel(): OrchestrationReadModel { + return { + snapshotSequence: 1, + updatedAt: now, + projects: [], + threads: [ + { + id: threadId, + projectId: ProjectId.make("project-inbox"), + title: "Inbox Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + effectiveCwd: null, + goal: null, + latestTurn: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + pinnedAt: null, + doneOverride: null, + lastSeenAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + diffStatBaselineTurnCount: 0, + session: null, + }, + ], + }; +} + +describe("decider inbox lifecycle", () => { + it("stores the client's stamp verbatim so the inbox can weigh it against activity", async () => { + const decided = await Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "thread.done-override.set", + commandId: CommandId.make("cmd-done-override"), + threadId, + state: "done", + at: "2026-01-02T09:30:00.000Z", + }, + readModel: makeReadModel(), + }), + ); + const event = Array.isArray(decided) ? decided[0] : decided; + + expect(event).toMatchObject({ + type: "thread.done-override-set", + aggregateKind: "thread", + aggregateId: threadId, + payload: { threadId, state: "done", at: "2026-01-02T09:30:00.000Z" }, + }); + // Filing a thread is not work on it: an `updatedAt` here would make the + // override look fresher than the activity it is supposed to be judged + // against, and would restart the auto-wrap idle clock. + expect(event?.payload).not.toHaveProperty("updatedAt"); + }); + + it("accepts a seen stamp that moves backwards, which is how mark-unread works", async () => { + const decided = await Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "thread.seen.set", + commandId: CommandId.make("cmd-seen-backwards"), + threadId, + at: "1999-01-01T00:00:00.000Z", + }, + readModel: makeReadModel(), + }), + ); + const event = Array.isArray(decided) ? decided[0] : decided; + + expect(event).toMatchObject({ + type: "thread.seen-set", + payload: { threadId, at: "1999-01-01T00:00:00.000Z" }, + }); + expect(event?.payload).not.toHaveProperty("updatedAt"); + }); + + it("rejects filing a thread that does not exist", async () => { + await expect( + Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "thread.done-override.set", + commandId: CommandId.make("cmd-done-missing"), + threadId: ThreadId.make("thread-missing"), + state: "done", + at: now, + }, + readModel: makeReadModel(), + }), + ), + ).rejects.toThrow("does not exist"); + }); +}); diff --git a/apps/server/src/orchestration/decider.proposedPlan.test.ts b/apps/server/src/orchestration/decider.proposedPlan.test.ts index 0d5992fb..e77a0ae6 100644 --- a/apps/server/src/orchestration/decider.proposedPlan.test.ts +++ b/apps/server/src/orchestration/decider.proposedPlan.test.ts @@ -57,6 +57,8 @@ function makeReadModel(proposedPlan: OrchestrationProposedPlan): OrchestrationRe updatedAt: now, archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, deletedAt: null, messages: [], proposedPlans: [proposedPlan], diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 3379ba3c..4d8d45da 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -514,6 +514,56 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.done-override.set": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const occurredAt = yield* nowIso; + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + }), + type: "thread.done-override-set", + payload: { + threadId: command.threadId, + state: command.state, + // The client's stamp, not `occurredAt`: the inbox compares this + // against the thread's activity to decide whether the word still + // stands. + at: command.at, + }, + }; + } + + case "thread.seen.set": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const occurredAt = yield* nowIso; + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + }), + type: "thread.seen-set", + payload: { + threadId: command.threadId, + // Last-write-wins, backwards included: "mark unread" sets seen to + // just before the completion it is un-seeing. + at: command.at, + }, + }; + } + case "thread.meta.update": { const thread = yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/decider.turnRetry.test.ts b/apps/server/src/orchestration/decider.turnRetry.test.ts index 0b89c50f..8432eef9 100644 --- a/apps/server/src/orchestration/decider.turnRetry.test.ts +++ b/apps/server/src/orchestration/decider.turnRetry.test.ts @@ -60,6 +60,8 @@ function makeThread(overrides: Partial = {}): Orchestration updatedAt: now, archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, deletedAt: null, messages: [ { diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 5efbd092..c6aecdf4 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -92,6 +92,8 @@ describe("orchestration projector", () => { updatedAt: now, archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, deletedAt: null, messages: [], proposedPlans: [], @@ -210,6 +212,94 @@ describe("orchestration projector", () => { expect(unarchived.threads[0]?.archivedAt).toBeNull(); }); + it("applies inbox lifecycle events without counting them as thread activity", async () => { + const now = "2026-01-01T00:00:00.000Z"; + const later = "2026-01-02T00:00:00.000Z"; + const created = await Effect.runPromise( + projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: now, + commandId: "cmd-thread-create", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "demo", + modelSelection: { + provider: ProviderDriverKind.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ), + ); + expect(created.threads[0]?.doneOverride).toBeNull(); + expect(created.threads[0]?.lastSeenAt).toBeNull(); + + const filed = await Effect.runPromise( + projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.done-override-set", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: later, + commandId: "cmd-thread-done", + payload: { threadId: "thread-1", state: "done", at: later }, + }), + ), + ); + expect(filed.threads[0]?.doneOverride).toEqual({ state: "done", at: later }); + // The inbox compares the override against the thread's last activity, and + // auto-wrap idles off the same stamp -- filing must not touch either. + expect(filed.threads[0]?.updatedAt).toBe(now); + + // Mark unread moves seen to just before the completion it un-sees, so a + // backwards stamp has to win. + const seenBackwards = await Effect.runPromise( + projectEvent( + filed, + makeEvent({ + sequence: 3, + type: "thread.seen-set", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: later, + commandId: "cmd-thread-seen-forward", + payload: { threadId: "thread-1", at: later }, + }), + ), + ).then((forward) => + Effect.runPromise( + projectEvent( + forward, + makeEvent({ + sequence: 4, + type: "thread.seen-set", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: later, + commandId: "cmd-thread-seen-backwards", + payload: { threadId: "thread-1", at: now }, + }), + ), + ), + ); + expect(seenBackwards.threads[0]?.lastSeenAt).toBe(now); + expect(seenBackwards.threads[0]?.updatedAt).toBe(now); + }); + it("applies thread.pinned and thread.unpinned events", async () => { const now = "2026-01-01T00:00:00.000Z"; const later = "2026-01-01T00:00:01.000Z"; diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 80559196..4deede25 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -24,11 +24,13 @@ import { ThreadArchivedPayload, ThreadCreatedPayload, ThreadDeletedPayload, + ThreadDoneOverrideSetPayload, ThreadInteractionModeSetPayload, ThreadMetaUpdatedPayload, ThreadPinnedPayload, ThreadProposedPlanUpsertedPayload, ThreadRuntimeModeSetPayload, + ThreadSeenSetPayload, ThreadUnarchivedPayload, ThreadUnpinnedPayload, ThreadRevertedPayload, @@ -278,6 +280,8 @@ export function projectEvent( updatedAt: payload.updatedAt, archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, deletedAt: null, messages: [], activities: [], @@ -351,6 +355,33 @@ export function projectEvent( })), ); + // Neither lifecycle event touches `updatedAt`: filing or reading a thread + // is not work on it, and the inbox weighs these stamps against activity. + case "thread.done-override-set": + return decodeForEvent( + ThreadDoneOverrideSetPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + doneOverride: { state: payload.state, at: payload.at }, + }), + })), + ); + + case "thread.seen-set": + return decodeForEvent(ThreadSeenSetPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + lastSeenAt: payload.at, + }), + })), + ); + case "thread.meta-updated": return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index a61ae67f..58b6ed6b 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -49,6 +49,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { updated_at, archived_at, pinned_at, + done_override, + done_override_at, + last_seen_at, latest_user_message_at, pending_approval_count, pending_user_input_count, @@ -73,6 +76,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.updatedAt}, ${row.archivedAt}, ${row.pinnedAt}, + ${row.doneOverride ?? null}, + ${row.doneOverrideAt ?? null}, + ${row.lastSeenAt ?? null}, ${row.latestUserMessageAt}, ${row.pendingApprovalCount}, ${row.pendingUserInputCount}, @@ -97,6 +103,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { updated_at = excluded.updated_at, archived_at = excluded.archived_at, pinned_at = excluded.pinned_at, + done_override = excluded.done_override, + done_override_at = excluded.done_override_at, + last_seen_at = excluded.last_seen_at, latest_user_message_at = excluded.latest_user_message_at, pending_approval_count = excluded.pending_approval_count, pending_user_input_count = excluded.pending_user_input_count, @@ -128,6 +137,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { updated_at AS "updatedAt", archived_at AS "archivedAt", pinned_at AS "pinnedAt", + done_override AS "doneOverride", + done_override_at AS "doneOverrideAt", + last_seen_at AS "lastSeenAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -161,6 +173,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { updated_at AS "updatedAt", archived_at AS "archivedAt", pinned_at AS "pinnedAt", + done_override AS "doneOverride", + done_override_at AS "doneOverrideAt", + last_seen_at AS "lastSeenAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 529e0897..c87c2a11 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -54,6 +54,7 @@ import Migration0038 from "./Migrations/038_ProjectionThreadsGoal.ts"; import Migration0039 from "./Migrations/039_ProjectionThreadProposedPlansDismissedAt.ts"; import Migration0040 from "./Migrations/040_ProjectionThreadsVoiceActive.ts"; import Migration0041 from "./Migrations/041_ProjectionThreadsDiffStatBaseline.ts"; +import Migration0042 from "./Migrations/042_ProjectionThreadsInboxLifecycle.ts"; /** * Migration loader with all migrations defined inline. @@ -107,6 +108,7 @@ export const migrationEntries = [ [39, "ProjectionThreadProposedPlansDismissedAt", Migration0039], [40, "ProjectionThreadsVoiceActive", Migration0040], [41, "ProjectionThreadsDiffStatBaseline", Migration0041], + [42, "ProjectionThreadsInboxLifecycle", Migration0042], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/042_ProjectionThreadsInboxLifecycle.ts b/apps/server/src/persistence/Migrations/042_ProjectionThreadsInboxLifecycle.ts new file mode 100644 index 00000000..b95fd81d --- /dev/null +++ b/apps/server/src/persistence/Migrations/042_ProjectionThreadsInboxLifecycle.ts @@ -0,0 +1,32 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + // The inbox's Active/Wrapped split used to live in each browser's + // localStorage, so a phone and a desktop disagreed about it. Existing rows + // stay NULL, which reads as "the user never filed this thread" and "never + // seen" -- the same starting point a device with empty storage had. + if (!columns.some((column) => column.name === "done_override")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN done_override TEXT + `; + } + if (!columns.some((column) => column.name === "done_override_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN done_override_at TEXT + `; + } + if (!columns.some((column) => column.name === "last_seen_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN last_seen_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index adb3dd37..6516e8b0 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -14,6 +14,7 @@ import { ProjectId, ProviderInteractionMode, RuntimeMode, + ThreadDoneOverrideState, ThreadId, TurnId, } from "@threadlines/contracts"; @@ -47,6 +48,15 @@ export const ProjectionThread = Schema.Struct({ updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime), pinnedAt: Schema.NullOr(IsoDateTime), + /** + * The user's explicit inbox filing and its stamp. Optional so rows written + * before migration 042 decode; absent reads as "never filed". Kept as two + * columns rather than a struct so the pair matches the projection table. + */ + doneOverride: Schema.optional(Schema.NullOr(ThreadDoneOverrideState)), + doneOverrideAt: Schema.optional(Schema.NullOr(IsoDateTime)), + /** When the user last saw the thread; see migration 042. */ + lastSeenAt: Schema.optional(Schema.NullOr(IsoDateTime)), latestUserMessageAt: Schema.NullOr(IsoDateTime), pendingApprovalCount: NonNegativeInt, pendingUserInputCount: NonNegativeInt, diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 83592534..96ac69f9 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -110,6 +110,8 @@ function makeReadModel( updatedAt: now, archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, latestUserMessageAt: null, hasPendingApprovals: false, hasPendingUserInput: false, diff --git a/apps/server/src/provider/ProviderReviewCoordinator.test.ts b/apps/server/src/provider/ProviderReviewCoordinator.test.ts index cdd2ca90..3a07d936 100644 --- a/apps/server/src/provider/ProviderReviewCoordinator.test.ts +++ b/apps/server/src/provider/ProviderReviewCoordinator.test.ts @@ -47,6 +47,8 @@ function makeThreadShell( updatedAt: NOW, archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index c066a19b..a924769c 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -181,6 +181,8 @@ const makeDefaultOrchestrationReadModel = () => { updatedAt: now, archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, latestTurn: null, messages: [], session: null, @@ -214,6 +216,8 @@ const makeDefaultOrchestrationThreadShell = ( updatedAt: now, archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, @@ -3425,6 +3429,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { updatedAt: now, archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, latestTurn: null, messages: [], session: null, diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 124c149d..d770f794 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -97,6 +97,15 @@ const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const REMOTE_ENVIRONMENT_ID = EnvironmentId.make("environment-remote"); const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID); const THREAD_KEY = scopedThreadKey(THREAD_REF); +/** + * File a thread from a test without a server round-trip: the optimistic + * overlay is exactly what the real Mark done action shows first. + */ +function markThreadDoneInTest(threadKey: string, at: string) { + useUiStateStore + .getState() + .setDoneOverlay(threadKey, { state: "done", at, baselineAt: null, settled: false }); +} const UUID_ROUTE_RE = /^\/draft\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const PROJECT_DRAFT_KEY = `${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`; const PROJECT_LOGICAL_KEY = deriveLogicalProjectKeyFromSettings( @@ -411,6 +420,8 @@ function createSnapshotForTargetUser(options: { updatedAt: NOW_ISO, archivedAt: null, pinnedAt: options.threadPinnedAt ?? null, + doneOverride: null, + lastSeenAt: null, deletedAt: null, messages, activities: [], @@ -482,6 +493,8 @@ function addThreadToSnapshot( updatedAt: NOW_ISO, archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, deletedAt: null, messages: [], activities: [], @@ -519,6 +532,8 @@ function toShellThread(thread: OrchestrationReadModel["threads"][number]) { updatedAt: thread.updatedAt, archivedAt: thread.archivedAt, pinnedAt: thread.pinnedAt, + doneOverride: thread.doneOverride, + lastSeenAt: thread.lastSeenAt, session: thread.session, latestUserMessageAt: thread.messages.findLast((message) => message.role === "user")?.createdAt ?? null, @@ -1043,6 +1058,8 @@ function createSnapshotWithSecondaryProject(options?: { updatedAt: isoAt(31), deletedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, messages: [], activities: [], proposedPlans: [], @@ -1081,6 +1098,8 @@ function createSnapshotWithSecondaryProject(options?: { updatedAt: isoAt(25), deletedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, messages: [], activities: [], proposedPlans: [], @@ -1250,6 +1269,12 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { return customResult; } const tag = body._tag; + // Accept commands by default, the way a connected server does. Optimistic UI + // rolls back on a rejected dispatch, so without this every action that goes + // through the orchestration command path silently snaps back. + if (tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { sequence: fixture.snapshot.snapshotSequence + 1 }; + } if (tag === WS_METHODS.serverGetConfig) { return encodeServerConfig(fixture.serverConfig); } @@ -2100,8 +2125,9 @@ describe("ChatView timeline estimator parity (full app)", () => { useUiStateStore.setState({ projectExpandedById: {}, projectOrder: [], - threadLastVisitedAtById: {}, - doneThreadOverrides: {}, + seenThreadOverlays: {}, + threadSeedVisitedAtById: {}, + doneThreadOverlays: {}, inboxProjectScopeKey: null, }); useTerminalStateStore.persist.clearStorage(); @@ -2347,7 +2373,8 @@ describe("ChatView timeline estimator parity (full app)", () => { [PROJECT_LOGICAL_KEY]: false, }, projectOrder: [PROJECT_LOGICAL_KEY], - threadLastVisitedAtById: {}, + seenThreadOverlays: {}, + threadSeedVisitedAtById: {}, }); const mounted = await mountChatView({ @@ -5452,8 +5479,8 @@ describe("ChatView timeline estimator parity (full app)", () => { scopeThreadRef(LOCAL_ENVIRONMENT_ID, otherDoneThreadId), ); const doneAt = new Date().toISOString(); - useUiStateStore.getState().markThreadDone(THREAD_KEY, doneAt); - useUiStateStore.getState().markThreadDone(otherThreadKey, doneAt); + markThreadDoneInTest(THREAD_KEY, doneAt); + markThreadDoneInTest(otherThreadKey, doneAt); await vi.waitFor( () => { @@ -5578,7 +5605,7 @@ describe("ChatView timeline estimator parity (full app)", () => { // live -> done -> archived: archive is only offered once a thread has // been filed away. await expect.element(page.getByTestId(`thread-row-${THREAD_ID}`)).toBeInTheDocument(); - useUiStateStore.getState().markThreadDone(THREAD_KEY, new Date().toISOString()); + markThreadDoneInTest(THREAD_KEY, new Date().toISOString()); const doneRow = page.getByTestId(`done-row-${THREAD_ID}`); await expect.element(doneRow).toBeInTheDocument(); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index c0a4c4e5..c7e8bf15 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1678,6 +1678,8 @@ const makeThread = (input?: { createdAt: "2026-03-29T00:00:00.000Z", archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, updatedAt: "2026-03-29T00:00:00.000Z", latestTurn: input?.latestTurn ? { @@ -1788,6 +1790,8 @@ function setStoreThreads(threads: ReadonlyArray>) createdAt: thread.createdAt, archivedAt: thread.archivedAt, pinnedAt: thread.pinnedAt, + doneOverride: thread.doneOverride, + lastSeenAt: thread.lastSeenAt, updatedAt: thread.updatedAt, branch: thread.branch, worktreePath: thread.worktreePath, @@ -2048,6 +2052,8 @@ describe("hasServerAcknowledgedLocalDispatch", () => { createdAt: "2026-03-29T00:00:00.000Z", archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, updatedAt: "2026-03-29T00:00:10.000Z", latestTurn: previousLatestTurn, branch: null, @@ -2088,6 +2094,8 @@ describe("hasServerAcknowledgedLocalDispatch", () => { createdAt: "2026-03-29T00:00:00.000Z", archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, updatedAt: "2026-03-29T00:00:10.000Z", latestTurn: previousLatestTurn, branch: null, @@ -2137,6 +2145,8 @@ describe("hasServerAcknowledgedLocalDispatch", () => { createdAt: "2026-03-29T00:00:00.000Z", archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, updatedAt: "2026-03-29T00:00:10.000Z", latestTurn: previousLatestTurn, branch: null, @@ -2183,6 +2193,8 @@ describe("hasServerAcknowledgedLocalDispatch", () => { createdAt: "2026-03-29T00:00:00.000Z", archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, updatedAt: "2026-03-29T00:00:10.000Z", latestTurn: previousLatestTurn, branch: null, @@ -2229,6 +2241,8 @@ describe("hasServerAcknowledgedLocalDispatch", () => { createdAt: "2026-03-29T00:00:00.000Z", archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, updatedAt: "2026-03-29T00:00:10.000Z", latestTurn: previousLatestTurn, branch: null, @@ -2282,6 +2296,8 @@ describe("hasServerAcknowledgedLocalDispatch", () => { createdAt: "2026-03-29T00:00:00.000Z", archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, updatedAt: "2026-03-29T00:00:10.000Z", latestTurn: previousLatestTurn, branch: null, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 9f7bd000..00ae49ac 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -133,6 +133,8 @@ export function buildLocalDraftThread( createdAt: draftThread.createdAt, archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, latestTurn: null, branch: draftThread.branch, worktreePath: draftThread.worktreePath, @@ -172,6 +174,8 @@ export function mergeLocalDraftThreadWithServerThread( createdAt: serverThread.createdAt, archivedAt: serverThread.archivedAt, pinnedAt: serverThread.pinnedAt, + doneOverride: serverThread.doneOverride, + lastSeenAt: serverThread.lastSeenAt, updatedAt: serverThread.updatedAt, latestTurn: serverThread.latestTurn, pendingSourceProposedPlan: serverThread.pendingSourceProposedPlan, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c1e1f513..3c0d3aa1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -127,6 +127,7 @@ import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { ChevronDownIcon, CornerDownRightIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; import { cn, randomUUID } from "~/lib/utils"; +import { markThreadSeen, selectThreadLastSeenAt } from "~/lib/threadInboxSync"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; @@ -1030,9 +1031,10 @@ export default function ChatView(props: ChatViewProps) { useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), ); const setStoreThreadError = useStore((store) => store.setError); - const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const activeThreadLastVisitedAt = useUiStateStore((store) => - routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined, + routeKind === "server" + ? selectThreadLastSeenAt(store, routeThreadKey, serverThread?.lastSeenAt) + : undefined, ); const settings = useSettings(); const { updateSettings } = useUpdateSettings(); @@ -1615,15 +1617,14 @@ export default function ChatView(props: ChatViewProps) { const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; - markThreadVisited( - scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), + markThreadSeen( + scopeThreadRef(serverThread.environmentId, serverThread.id), activeLatestTurn.completedAt, ); }, [ activeLatestTurn?.completedAt, activeThreadLastVisitedAt, latestTurnSettled, - markThreadVisited, serverThread?.environmentId, serverThread?.id, ]); diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 1a3f31a4..b39ad99c 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -34,6 +34,8 @@ function makeThread(overrides: Partial = {}): Thread { createdAt: "2026-03-01T00:00:00.000Z", archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, updatedAt: "2026-03-01T00:00:00.000Z", latestTurn: null, branch: null, diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index f3540708..3dd4aecf 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -185,6 +185,8 @@ function createMinimalSnapshot(): OrchestrationReadModel { updatedAt: NOW_ISO, archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, deletedAt: null, messages: [ { @@ -245,6 +247,8 @@ function toShellSnapshot(snapshot: OrchestrationReadModel) { updatedAt: thread.updatedAt, archivedAt: thread.archivedAt, pinnedAt: thread.pinnedAt, + doneOverride: thread.doneOverride, + lastSeenAt: thread.lastSeenAt, session: thread.session, latestUserMessageAt: thread.messages.findLast((message) => message.role === "user")?.createdAt ?? null, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 80e4aca8..1780c087 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -21,6 +21,8 @@ import { import { buildProjectScopeOptions, isThreadDone, + mergeThreadDoneOverride, + mergeThreadLastSeenAt, sortInboxThreads, windowInboxThreads, } from "./Sidebar.logic"; @@ -754,6 +756,8 @@ function makeThread(overrides: Partial = {}): Thread { createdAt: "2026-03-09T10:00:00.000Z", archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, updatedAt: "2026-03-09T10:00:00.000Z", latestTurn: null, branch: null, @@ -1093,6 +1097,52 @@ describe("inbox done lifecycle", () => { }); }); +describe("merging device-local and server-held inbox state", () => { + const serverMark = { state: "done", at: "2026-07-28T10:00:00.000Z" } as const; + + it("lets the freshest word win, whichever device gave it", () => { + const newerLocal = { state: "active", at: "2026-07-28T11:00:00.000Z" } as const; + const olderLocal = { state: "active", at: "2026-07-28T09:00:00.000Z" } as const; + + expect(mergeThreadDoneOverride(newerLocal, serverMark)).toBe(newerLocal); + expect(mergeThreadDoneOverride(olderLocal, serverMark)).toBe(serverMark); + }); + + it("falls back to whichever side has a word at all", () => { + expect(mergeThreadDoneOverride(undefined, serverMark)).toBe(serverMark); + expect(mergeThreadDoneOverride(serverMark, null)).toBe(serverMark); + expect(mergeThreadDoneOverride(null, null)).toBeNull(); + }); + + it("prefers the server on a tie, because a landed write is the same word twice", () => { + const sameStamp = { state: "done", at: serverMark.at } as const; + expect(mergeThreadDoneOverride(sameStamp, serverMark)).toBe(serverMark); + }); + + it("reads seen state as pending write, then server, then this device's seed", () => { + expect( + mergeThreadLastSeenAt({ + overlayAt: "2026-07-28T12:00:00.000Z", + serverLastSeenAt: "2026-07-28T10:00:00.000Z", + seedAt: "2026-07-27T12:00:00.000Z", + }), + ).toBe("2026-07-28T12:00:00.000Z"); + expect( + mergeThreadLastSeenAt({ + serverLastSeenAt: "2026-07-28T10:00:00.000Z", + seedAt: "2026-07-27T12:00:00.000Z", + }), + ).toBe("2026-07-28T10:00:00.000Z"); + // A thread the server has never recorded a visit for is treated as seen + // as of the moment this device first saw it, so a backlog does not arrive + // all-unread. + expect( + mergeThreadLastSeenAt({ serverLastSeenAt: null, seedAt: "2026-07-27T12:00:00.000Z" }), + ).toBe("2026-07-27T12:00:00.000Z"); + expect(mergeThreadLastSeenAt({ serverLastSeenAt: null })).toBeUndefined(); + }); +}); + describe("sortInboxThreads", () => { it("holds creation order and floats pins, nothing else", () => { const rows = [ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 5a4f4401..7f4e11fb 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -608,6 +608,37 @@ export interface ThreadDoneOverride { readonly at: string; } +/** + * One answer from two sources: the thread's server-held word (shared by every + * device) and this device's unconfirmed local write. Freshest stamp wins, and + * the server wins ties -- a landed write carries the same stamp it was sent + * with, so a tie is the same word twice, not a conflict. + */ +export function mergeThreadDoneOverride( + overlay: ThreadDoneOverride | null | undefined, + server: ThreadDoneOverride | null | undefined, +): ThreadDoneOverride | null { + const overlayAt = overlay ? toSortableTimestamp(overlay.at) : null; + const serverAt = server ? toSortableTimestamp(server.at) : null; + if (overlay == null || overlayAt === null) + return server != null && serverAt !== null ? server : null; + if (server == null || serverAt === null) return overlay; + return overlayAt > serverAt ? overlay : server; +} + +/** + * When the user last saw a thread. A pending local write speaks first, then + * the server's value, then this device's seed for threads the server has + * never recorded a visit for. + */ +export function mergeThreadLastSeenAt(input: { + readonly overlayAt?: string | undefined; + readonly serverLastSeenAt?: string | null | undefined; + readonly seedAt?: string | undefined; +}): string | undefined { + return input.overlayAt ?? input.serverLastSeenAt ?? input.seedAt ?? undefined; +} + const DAY_MS = 24 * 60 * 60 * 1_000; /** diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 88ac615a..ab4a70f8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -29,6 +29,11 @@ import { APP_BASE_NAME, APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; import { cn, isMacPlatform, newCommandId } from "../lib/utils"; import { toSortableTimestamp } from "../lib/threadSort"; +import { + markThreadDoneByKey, + markThreadUnreadByKey, + reopenThreadByKey, +} from "../lib/threadInboxSync"; import { selectProjectByRef, selectProjectsAcrossEnvironments, @@ -84,6 +89,8 @@ import { isNeedsUserStatus, INBOX_AUTO_DONE_AFTER_DAYS, isThreadDone, + mergeThreadDoneOverride, + mergeThreadLastSeenAt, resolveAdjacentThreadId, resolveDoneTimestamp, resolveSidebarNewThreadSeedContext, @@ -335,13 +342,11 @@ export default function Sidebar() { const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); const primaryEnvironmentId = usePrimaryEnvironmentId(); const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); - const threadLastVisitedAtById = useUiStateStore((store) => store.threadLastVisitedAtById); - const doneThreadOverrides = useUiStateStore((store) => store.doneThreadOverrides); + const seenThreadOverlays = useUiStateStore((store) => store.seenThreadOverlays); + const threadSeedVisitedAtById = useUiStateStore((store) => store.threadSeedVisitedAtById); + const doneThreadOverlays = useUiStateStore((store) => store.doneThreadOverlays); const inboxProjectScopeKey = useUiStateStore((store) => store.inboxProjectScopeKey); const setInboxProjectScope = useUiStateStore((store) => store.setInboxProjectScope); - const markThreadDoneInStore = useUiStateStore((store) => store.markThreadDone); - const reopenThreadInStore = useUiStateStore((store) => store.reopenThread); - const markThreadUnread = useUiStateStore((store) => store.markThreadUnread); const navigate = useNavigate(); const router = useRouter(); const pathname = useLocation({ select: (loc) => loc.pathname }); @@ -524,7 +529,11 @@ export default function Sidebar() { () => inboxThreads.map((thread) => { const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const lastVisitedAt = threadLastVisitedAtById[threadKey]; + const lastVisitedAt = mergeThreadLastSeenAt({ + overlayAt: seenThreadOverlays[threadKey]?.at, + serverLastSeenAt: thread.lastSeenAt, + seedAt: threadSeedVisitedAtById[threadKey], + }); const status = resolveThreadStatusPill({ thread: { ...thread, @@ -532,7 +541,10 @@ export default function Sidebar() { }, }); const projectKey = resolveThreadProjectKey(thread); - const override = doneThreadOverrides[threadKey]; + const override = mergeThreadDoneOverride( + doneThreadOverlays[threadKey], + thread.doneOverride, + ); const isDone = isThreadDone({ ...thread, lastVisitedAt }, override, { now: nowIso, autoDoneAfterDays: INBOX_AUTO_DONE_AFTER_DAYS, @@ -549,12 +561,13 @@ export default function Sidebar() { }; }), [ - doneThreadOverrides, + doneThreadOverlays, inboxThreads, nowIso, resolveThreadProjectKey, + seenThreadOverlays, sidebarProjectByKey, - threadLastVisitedAtById, + threadSeedVisitedAtById, ], ); @@ -605,10 +618,13 @@ export default function Sidebar() { const doneEntries = sortDoneThreads( scoped.filter((entry) => entry.isDone).map((entry) => entry.thread), (thread) => - doneThreadOverrides[scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))], + mergeThreadDoneOverride( + doneThreadOverlays[scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))], + thread.doneOverride, + ), ).map(lookup); return { liveEntries, doneEntries }; - }, [doneThreadOverrides, entries, scopedProjectKeyValue]); + }, [doneThreadOverlays, entries, scopedProjectKeyValue]); // Volume is managed by folding, not by flattening rows: quiet threads past // the limit fold away, and anything with a status stays put. @@ -776,18 +792,12 @@ export default function Sidebar() { [pinThread, unpinThread], ); - const markThreadDone = useCallback( - (threadKey: string) => { - markThreadDoneInStore(threadKey, new Date().toISOString()); - }, - [markThreadDoneInStore], - ); - const reopenThread = useCallback( - (threadKey: string) => { - reopenThreadInStore(threadKey, new Date().toISOString()); - }, - [reopenThreadInStore], - ); + const markThreadDone = useCallback((threadKey: string) => { + markThreadDoneByKey(threadKey); + }, []); + const reopenThread = useCallback((threadKey: string) => { + reopenThreadByKey(threadKey); + }, []); const cancelRename = useCallback(() => { setRenamingThreadKey(null); @@ -860,7 +870,7 @@ export default function Sidebar() { if (clicked === "mark-unread") { for (const threadKey of threadKeys) { const thread = sidebarThreadByKeyRef.current.get(threadKey); - markThreadUnread(threadKey, thread?.latestTurn?.completedAt); + markThreadUnreadByKey(threadKey, thread?.latestTurn?.completedAt); } clearSelection(); return; @@ -886,13 +896,7 @@ export default function Sidebar() { } removeFromSelection(threadKeys); }, - [ - appSettingsConfirmThreadDelete, - clearSelection, - deleteThread, - markThreadUnread, - removeFromSelection, - ], + [appSettingsConfirmThreadDelete, clearSelection, deleteThread, removeFromSelection], ); const handleThreadContextMenu = useCallback( @@ -935,7 +939,7 @@ export default function Sidebar() { } if (clicked === "mark-unread") { - markThreadUnread(threadKey, thread.latestTurn?.completedAt); + markThreadUnreadByKey(threadKey, thread.latestTurn?.completedAt); return; } if (clicked === "copy-path") { @@ -976,7 +980,6 @@ export default function Sidebar() { copyPathToClipboard, copyThreadIdToClipboard, deleteThread, - markThreadUnread, ], ); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 8cad1763..6d4a9540 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -9,6 +9,7 @@ import { useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; import { useGitStatus } from "../lib/gitStatusState"; +import { selectThreadLastSeenAt } from "../lib/threadInboxSync"; import { cn } from "../lib/utils"; import { type AppState, selectProjectByRef, useStore } from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; @@ -175,8 +176,8 @@ export function ThreadStatusLabel({ */ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummary }) { const threadRef = scopeThreadRef(thread.environmentId, thread.id); - const lastVisitedAt = useUiStateStore( - (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)], + const lastVisitedAt = useUiStateStore((state) => + selectThreadLastSeenAt(state, scopedThreadKey(threadRef), thread.lastSeenAt), ); const threadProjectCwd = useStore( useMemo( diff --git a/apps/web/src/components/settings/ExtensionsSettings.logic.test.ts b/apps/web/src/components/settings/ExtensionsSettings.logic.test.ts index 00084c55..1282f9c0 100644 --- a/apps/web/src/components/settings/ExtensionsSettings.logic.test.ts +++ b/apps/web/src/components/settings/ExtensionsSettings.logic.test.ts @@ -378,11 +378,9 @@ describe("ExtensionsSettings logic", () => { providerInstanceId: "codex", providerThreadId: "codex-thread-new", updatedAt: "2026-05-30T09:00:00.000Z", + lastSeenAt: "2026-05-30T11:00:00.000Z", }, ], - threadLastVisitedAtById: { - "local:thread-new": "2026-05-30T11:00:00.000Z", - }, }), ).toBe("codex-thread-new"); }); @@ -421,7 +419,6 @@ describe("ExtensionsSettings logic", () => { updatedAt: "2026-05-30T10:00:00.000Z", }, ], - threadLastVisitedAtById: {}, }), ).toBe("exact-thread"); }); @@ -459,7 +456,6 @@ describe("ExtensionsSettings logic", () => { providerThreadId: "other-provider-thread", }, ], - threadLastVisitedAtById: {}, }), ).toBe(""); }); diff --git a/apps/web/src/components/settings/ExtensionsSettings.logic.ts b/apps/web/src/components/settings/ExtensionsSettings.logic.ts index e0f3c733..4606092b 100644 --- a/apps/web/src/components/settings/ExtensionsSettings.logic.ts +++ b/apps/web/src/components/settings/ExtensionsSettings.logic.ts @@ -355,6 +355,8 @@ export interface ExtensionProviderThreadCandidate { readonly createdAt?: string | undefined; readonly updatedAt?: string | undefined; readonly sessionUpdatedAt?: string | undefined; + /** Server-held "last seen"; part of how recently the user touched a thread. */ + readonly lastSeenAt?: string | null | undefined; } export interface ExtensionInventoryCacheKeyInput { @@ -608,14 +610,12 @@ export function deriveDetectedProviderThreadId({ providerInstanceId, projects, threads, - threadLastVisitedAtById, }: { readonly cwd: string; readonly providerDriver: string; readonly providerInstanceId: string; readonly projects: ReadonlyArray; readonly threads: ReadonlyArray; - readonly threadLastVisitedAtById: Readonly>; }): string { const cwdKey = normalizedCwdKey(cwd); const selectedProviderDriver = providerDriver.trim(); @@ -647,7 +647,7 @@ export function deriveDetectedProviderThreadId({ const instanceRank = candidateInstanceId === selectedProviderInstanceId ? 1 : 0; const timestamp = Math.max( - parsedTime(threadLastVisitedAtById[thread.key]), + parsedTime(thread.lastSeenAt ?? undefined), parsedTime(thread.sessionUpdatedAt), parsedTime(thread.updatedAt), parsedTime(thread.createdAt), diff --git a/apps/web/src/components/settings/ExtensionsSettings.tsx b/apps/web/src/components/settings/ExtensionsSettings.tsx index 3a79bfc7..2c794d0d 100644 --- a/apps/web/src/components/settings/ExtensionsSettings.tsx +++ b/apps/web/src/components/settings/ExtensionsSettings.tsx @@ -87,7 +87,6 @@ import { selectWorkspaceProjectsAcrossEnvironments, useStore, } from "../../store"; -import { useUiStateStore } from "../../uiStateStore"; import { providerMcpLoginCommand, type ExtensionMcpLoginProvider } from "../../mcpAuthStatus"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; @@ -3983,7 +3982,6 @@ export function ExtensionsSettingsPanel() { const projects = useStore(useShallow(selectWorkspaceProjectsAcrossEnvironments)); const threads = useStore(useShallow(selectThreadsAcrossEnvironments)); const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); - const threadLastVisitedAtById = useUiStateStore((state) => state.threadLastVisitedAtById); const serverConfig = useServerConfig(); const serverProviders = useServerProviders(); const projectOptions = useMemo( @@ -4074,10 +4072,10 @@ export function ExtensionsSettingsPanel() { createdAt: thread.createdAt, updatedAt: thread.updatedAt, sessionUpdatedAt: thread.session?.updatedAt, + lastSeenAt: thread.lastSeenAt, })), - threadLastVisitedAtById, }), - [cwd, projects, threadScopeProviderEntry, threadLastVisitedAtById, threads], + [cwd, projects, threadScopeProviderEntry, threads], ); const effectiveProviderThreadId = manualProviderThreadId.trim() || detectedProviderThreadId; const providerThreadContextSource = manualProviderThreadId.trim() diff --git a/apps/web/src/components/sidebar/ThreadHoverCard.browser.tsx b/apps/web/src/components/sidebar/ThreadHoverCard.browser.tsx index 07c45a3d..4900118c 100644 --- a/apps/web/src/components/sidebar/ThreadHoverCard.browser.tsx +++ b/apps/web/src/components/sidebar/ThreadHoverCard.browser.tsx @@ -46,6 +46,8 @@ function thread(overrides: Partial = {}): SidebarThreadSum createdAt: "2026-07-23T00:00:00.000Z", archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, latestTurn: null, branch: null, worktreePath: null, diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 01134ba2..3bfb6468 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -73,6 +73,8 @@ function makeSidebarThreadSummary( createdAt: "2026-01-01T00:00:00.000Z", archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, updatedAt: "2026-01-01T00:00:00.000Z", latestTurn: null, branch: null, diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts index 21c20869..192cbf3d 100644 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts @@ -129,6 +129,8 @@ function makeThreadShellSnapshot(params: { updatedAt: "2026-04-13T00:00:00.000Z", archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, session: params.sessionStatus ? { threadId: params.threadId, diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index b45c3557..a8789e2b 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -32,6 +32,7 @@ import { } from "~/composerDraftStore"; import { ensureLocalApi } from "~/localApi"; import { collectActiveTerminalThreadIds } from "~/lib/terminalStateCleanup"; +import { migrateLegacyInboxStateForEnvironment } from "~/lib/threadInboxSync"; import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects"; import { projectQueryKeys } from "~/lib/projectReactQuery"; import { providerQueryKeys } from "~/lib/providerReactQuery"; @@ -1031,6 +1032,8 @@ function syncThreadUiFromStore() { threads.map((thread) => ({ key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), seedVisitedAt: thread.updatedAt ?? thread.createdAt, + serverLastSeenAt: thread.lastSeenAt, + serverDoneOverrideAt: thread.doneOverride?.at ?? null, })), ); markPromotedDraftThreadsByRef( @@ -1101,16 +1104,15 @@ function applyRecoveredEventBatch( } const needsThreadUiSync = events.some( - (event) => event.type === "thread.created" || event.type === "thread.deleted", + (event) => + event.type === "thread.created" || + event.type === "thread.deleted" || + // Retires the optimistic overlay these events confirm or supersede. + event.type === "thread.seen-set" || + event.type === "thread.done-override-set", ); if (needsThreadUiSync) { - const threads = selectThreadsAcrossEnvironments(useStore.getState()); - useUiStateStore.getState().syncThreads( - threads.map((thread) => ({ - key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - seedVisitedAt: thread.updatedAt ?? thread.createdAt, - })), - ); + syncThreadUiFromStore(); } const draftStore = useComposerDraftStore.getState(); @@ -1218,6 +1220,9 @@ function createEnvironmentConnectionHandlers() { ); reconcileThreadDetailSubscriptionEvictionForEnvironment(environmentId); reconcileSnapshotDerivedState(); + // Deferred inside; the first snapshot is what makes the environment's + // threads (and their server-held lifecycle state) knowable. + migrateLegacyInboxStateForEnvironment(environmentId); }, applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => { const threadRef = scopeThreadRef(environmentId, ThreadId.make(event.threadId)); diff --git a/apps/web/src/lib/providerReview.test.ts b/apps/web/src/lib/providerReview.test.ts index 8d0bb1a7..044ff9f1 100644 --- a/apps/web/src/lib/providerReview.test.ts +++ b/apps/web/src/lib/providerReview.test.ts @@ -116,6 +116,8 @@ function serverThread(input: { createdAt: "2026-07-09T00:00:00.000Z", archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, latestTurn: null, branch: "main", worktreePath: null, diff --git a/apps/web/src/lib/threadInboxSync.ts b/apps/web/src/lib/threadInboxSync.ts new file mode 100644 index 00000000..d7ec3466 --- /dev/null +++ b/apps/web/src/lib/threadInboxSync.ts @@ -0,0 +1,263 @@ +/** + * Inbox lifecycle state, shared across devices. + * + * "Where does this thread belong" (Active vs Wrapped) and "have I seen it" + * used to live in each browser's localStorage, so a phone opened through the + * relay disagreed with the desktop about both. They now live on the thread on + * the server; this module is the one place that writes them. + * + * Every write is optimistic: the row moves immediately from an in-memory + * overlay, the command goes out, and the overlay retires once the server's + * value moves off the baseline the write was made against. A rejected + * dispatch drops its overlay, so no path leaves a device showing a state the + * server never heard of. + */ +import { parseScopedThreadKey, scopeThreadRef, scopedThreadKey } from "@threadlines/client-runtime"; +import type { EnvironmentId, ScopedThreadRef } from "@threadlines/contracts"; + +import { mergeThreadDoneOverride, mergeThreadLastSeenAt } from "../components/Sidebar.logic"; +import type { ThreadDoneOverride } from "../components/Sidebar.logic"; +import { readEnvironmentApi } from "../environmentApi"; +import { + selectSidebarThreadSummaryByRef, + selectSidebarThreadsAcrossEnvironments, + useStore, +} from "../store"; +import { + dropLegacyInboxState, + readLegacyInboxState, + useUiStateStore, + type UiState, +} from "../uiStateStore"; +import { newCommandId } from "./utils"; + +/** This device's answer for a thread's inbox filing: overlay over server. */ +export function selectThreadDoneOverride( + uiState: UiState, + threadKey: string, + serverDoneOverride: ThreadDoneOverride | null | undefined, +): ThreadDoneOverride | null { + return mergeThreadDoneOverride(uiState.doneThreadOverlays[threadKey], serverDoneOverride); +} + +/** This device's answer for when a thread was last seen: overlay, server, seed. */ +export function selectThreadLastSeenAt( + uiState: UiState, + threadKey: string, + serverLastSeenAt: string | null | undefined, +): string | undefined { + return mergeThreadLastSeenAt({ + overlayAt: uiState.seenThreadOverlays[threadKey]?.at, + serverLastSeenAt, + seedAt: uiState.threadSeedVisitedAtById[threadKey], + }); +} + +function readServerLifecycle(threadRef: ScopedThreadRef): { + readonly doneOverride: ThreadDoneOverride | null; + readonly lastSeenAt: string | null; +} { + const summary = selectSidebarThreadSummaryByRef(useStore.getState(), threadRef); + return { + doneOverride: summary?.doneOverride ?? null, + lastSeenAt: summary?.lastSeenAt ?? null, + }; +} + +function dispatchDoneOverride( + threadRef: ScopedThreadRef, + state: "done" | "active", + at: string, +): Promise { + const api = readEnvironmentApi(threadRef.environmentId); + if (!api) { + return Promise.reject(new Error(`Environment ${threadRef.environmentId} is not connected.`)); + } + return api.orchestration + .dispatchCommand({ + type: "thread.done-override.set", + commandId: newCommandId(), + threadId: threadRef.threadId, + state, + at, + }) + .then(() => undefined); +} + +function dispatchSeen(threadRef: ScopedThreadRef, at: string): Promise { + const api = readEnvironmentApi(threadRef.environmentId); + if (!api) { + return Promise.reject(new Error(`Environment ${threadRef.environmentId} is not connected.`)); + } + return api.orchestration + .dispatchCommand({ + type: "thread.seen.set", + commandId: newCommandId(), + threadId: threadRef.threadId, + at, + }) + .then(() => undefined); +} + +/** + * File a thread (or pull it back). Always dispatched: it is the user saying + * so, and the same stamp lands in the overlay and on the server so the row + * cannot move twice. + */ +export function setThreadDoneOverride( + threadRef: ScopedThreadRef, + state: "done" | "active", + at: string = new Date().toISOString(), +): void { + const threadKey = scopedThreadKey(threadRef); + const uiStore = useUiStateStore.getState(); + uiStore.setDoneOverlay(threadKey, { + state, + at, + baselineAt: readServerLifecycle(threadRef).doneOverride?.at ?? null, + settled: false, + }); + void dispatchDoneOverride(threadRef, state, at).then( + () => useUiStateStore.getState().resolveDoneOverlay(threadKey, at, "confirmed"), + () => useUiStateStore.getState().resolveDoneOverlay(threadKey, at, "failed"), + ); +} + +export function markThreadDoneByKey(threadKey: string, at?: string): void { + const threadRef = parseScopedThreadKey(threadKey); + if (!threadRef) return; + setThreadDoneOverride(threadRef, "done", at); +} + +export function reopenThreadByKey(threadKey: string, at?: string): void { + const threadRef = parseScopedThreadKey(threadKey); + if (!threadRef) return; + setThreadDoneOverride(threadRef, "active", at); +} + +/** + * Record a visit. Only dispatched when it actually moves this device's answer + * forward, so scrolling through a thread does not chatter at the server. + */ +export function markThreadSeen(threadRef: ScopedThreadRef, visitedAt: string): void { + const threadKey = scopedThreadKey(threadRef); + const visitedAtMs = Date.parse(visitedAt); + if (Number.isNaN(visitedAtMs)) return; + const server = readServerLifecycle(threadRef); + const currentAt = selectThreadLastSeenAt( + useUiStateStore.getState(), + threadKey, + server.lastSeenAt, + ); + const currentMs = currentAt ? Date.parse(currentAt) : Number.NaN; + if (Number.isFinite(currentMs) && currentMs >= visitedAtMs) return; + writeSeen(threadRef, threadKey, visitedAt, server.lastSeenAt); +} + +/** + * Un-see a thread's latest completion. Deliberately moves the stamp backwards + * -- to a millisecond before the completion -- which is why seen is + * last-write-wins rather than advance-only on the server. + */ +export function markThreadUnreadByKey( + threadKey: string, + latestTurnCompletedAt: string | null | undefined, +): void { + const threadRef = parseScopedThreadKey(threadKey); + if (!threadRef || !latestTurnCompletedAt) return; + const completedAtMs = Date.parse(latestTurnCompletedAt); + if (Number.isNaN(completedAtMs)) return; + const unreadAt = new Date(completedAtMs - 1).toISOString(); + const server = readServerLifecycle(threadRef); + if (server.lastSeenAt === unreadAt) return; + writeSeen(threadRef, threadKey, unreadAt, server.lastSeenAt); +} + +function writeSeen( + threadRef: ScopedThreadRef, + threadKey: string, + at: string, + baselineAt: string | null, +): void { + useUiStateStore.getState().setSeenOverlay(threadKey, { at, baselineAt, settled: false }); + void dispatchSeen(threadRef, at).then( + () => useUiStateStore.getState().resolveSeenOverlay(threadKey, at, "confirmed"), + () => useUiStateStore.getState().resolveSeenOverlay(threadKey, at, "failed"), + ); +} + +const LEGACY_MIGRATION_FLAG_PREFIX = "threadlines:inbox-lifecycle-migrated:v1:"; +const migrationsInFlight = new Set(); + +function readMigrationFlag(flagKey: string): boolean { + try { + return window.localStorage.getItem(flagKey) !== null; + } catch { + return true; + } +} + +function writeMigrationFlag(flagKey: string): void { + try { + window.localStorage.setItem(flagKey, "1"); + } catch { + // Ignore quota/storage errors; the worst case is retrying next launch. + } +} + +/** + * Hand this device's pre-sync inbox state to the server, once per environment. + * + * Runs off the render path and only fills gaps: a legacy stamp is sent when + * the server has nothing (or, for a filing, something strictly older), so a + * device that has been offline for a week cannot undo newer decisions made + * elsewhere. + */ +export function migrateLegacyInboxStateForEnvironment(environmentId: EnvironmentId): void { + if (typeof window === "undefined") return; + const flagKey = `${LEGACY_MIGRATION_FLAG_PREFIX}${environmentId}`; + if (migrationsInFlight.has(flagKey) || readMigrationFlag(flagKey)) return; + migrationsInFlight.add(flagKey); + + const run = async () => { + try { + const legacy = readLegacyInboxState(); + const summaries = selectSidebarThreadsAcrossEnvironments(useStore.getState()).filter( + (thread) => thread.environmentId === environmentId, + ); + const migratedThreadKeys: string[] = []; + + for (const summary of summaries) { + const threadRef = scopeThreadRef(summary.environmentId, summary.id); + const threadKey = scopedThreadKey(threadRef); + const legacyDone = legacy.doneThreadOverrides[threadKey]; + const legacySeenAt = legacy.threadLastVisitedAtById[threadKey]; + if (legacyDone === undefined && legacySeenAt === undefined) { + continue; + } + migratedThreadKeys.push(threadKey); + + if ( + legacyDone !== undefined && + (summary.doneOverride === null || + Date.parse(summary.doneOverride.at) < Date.parse(legacyDone.at)) + ) { + await dispatchDoneOverride(threadRef, legacyDone.state, legacyDone.at).catch( + () => undefined, + ); + } + if (legacySeenAt !== undefined && summary.lastSeenAt === null) { + await dispatchSeen(threadRef, legacySeenAt).catch(() => undefined); + } + } + + dropLegacyInboxState(migratedThreadKeys); + writeMigrationFlag(flagKey); + } finally { + migrationsInFlight.delete(flagKey); + } + }; + + // Deferred so a connect never waits on storage reads or command round-trips. + setTimeout(() => void run(), 0); +} diff --git a/apps/web/src/lib/threadSort.test.ts b/apps/web/src/lib/threadSort.test.ts index 75d02641..1aceb640 100644 --- a/apps/web/src/lib/threadSort.test.ts +++ b/apps/web/src/lib/threadSort.test.ts @@ -30,6 +30,8 @@ function makeThread(overrides: Partial = {}): Thread { createdAt: "2026-03-09T10:00:00.000Z", archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, updatedAt: "2026-03-09T10:00:00.000Z", latestTurn: null, branch: null, @@ -197,6 +199,8 @@ describe("sortThreads", () => { }, ], pinnedAt: "2026-03-09T10:11:00.000Z", + doneOverride: null, + lastSeenAt: null, }), ], "updated_at", @@ -271,15 +275,23 @@ describe("selectActiveAndRecentThreads", () => { const threads = [ makeTimestampedThread("pinned-1", "2026-03-09T09:01:00.000Z", { pinnedAt: "2026-03-09T10:11:00.000Z", + doneOverride: null, + lastSeenAt: null, }), makeTimestampedThread("pinned-2", "2026-03-09T09:02:00.000Z", { pinnedAt: "2026-03-09T10:11:00.000Z", + doneOverride: null, + lastSeenAt: null, }), makeTimestampedThread("pinned-3", "2026-03-09T09:03:00.000Z", { pinnedAt: "2026-03-09T10:11:00.000Z", + doneOverride: null, + lastSeenAt: null, }), makeTimestampedThread("pinned-4", "2026-03-09T09:04:00.000Z", { pinnedAt: "2026-03-09T10:11:00.000Z", + doneOverride: null, + lastSeenAt: null, }), makeTimestampedThread("running-newer", "2026-03-09T10:10:00.000Z", { session }), makeTimestampedThread("running-older", "2026-03-09T09:00:00.000Z", { session }), diff --git a/apps/web/src/rpc/protocol.ts b/apps/web/src/rpc/protocol.ts index cf082a3c..2559f466 100644 --- a/apps/web/src/rpc/protocol.ts +++ b/apps/web/src/rpc/protocol.ts @@ -3,6 +3,8 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schedule from "effect/Schedule"; +import type * as Scope from "effect/Scope"; +import type { Rpc, RpcClientError, RpcGroup } from "effect/unstable/rpc"; import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import * as Socket from "effect/unstable/socket/Socket"; @@ -64,10 +66,19 @@ export interface WsProtocolLifecycleHandlers { ) => void; } -export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup); -type RpcClientFactory = typeof makeWsRpcProtocolClient; -export type WsRpcProtocolClient = - RpcClientFactory extends Effect.Effect ? Client : never; +type WsRpcs = typeof WsRpcGroup extends RpcGroup.RpcGroup ? Rpcs : never; +/** + * Named rather than inferred: the generated client expands to one method per + * RPC, so letting the compiler infer it puts the whole surface (every command + * payload included) inline, and adding a command tips it past the type + * serialization limit. + */ +export type WsRpcProtocolClient = RpcClient.RpcClient; +export const makeWsRpcProtocolClient: Effect.Effect< + WsRpcProtocolClient, + never, + RpcClient.Protocol | Rpc.MiddlewareClient | Scope.Scope +> = RpcClient.make(WsRpcGroup); export type WsRpcProtocolSocketUrlProvider = string | (() => Promise); export interface WsRpcProtocolOptions { diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 828871ae..8b746939 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -83,6 +83,8 @@ function makeThread(overrides: Partial = {}): Thread { createdAt: "2026-02-13T00:00:00.000Z", archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, latestTurn: null, branch: null, worktreePath: null, @@ -133,6 +135,8 @@ function makeState(thread: Thread): AppState { createdAt: thread.createdAt, archivedAt: thread.archivedAt, pinnedAt: thread.pinnedAt, + doneOverride: thread.doneOverride, + lastSeenAt: thread.lastSeenAt, updatedAt: thread.updatedAt, branch: thread.branch, worktreePath: thread.worktreePath, @@ -524,6 +528,8 @@ describe("incremental orchestration updates", () => { updatedAt: "2026-02-27T00:00:01.000Z", archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, @@ -1031,6 +1037,8 @@ describe("incremental orchestration updates", () => { createdAt: thread2.createdAt, archivedAt: thread2.archivedAt, pinnedAt: thread2.pinnedAt, + doneOverride: null, + lastSeenAt: null, updatedAt: thread2.updatedAt, branch: thread2.branch, worktreePath: thread2.worktreePath, diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 322d4fbd..c04e2ac9 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -287,6 +287,8 @@ function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): T createdAt: thread.createdAt, archivedAt: thread.archivedAt, pinnedAt: thread.pinnedAt, + doneOverride: thread.doneOverride, + lastSeenAt: thread.lastSeenAt, updatedAt: thread.updatedAt, latestTurn: thread.latestTurn, pendingSourceProposedPlan: thread.latestTurn?.sourceProposedPlan, @@ -324,6 +326,8 @@ function mapThreadShell( createdAt: thread.createdAt, archivedAt: thread.archivedAt, pinnedAt: thread.pinnedAt, + doneOverride: thread.doneOverride, + lastSeenAt: thread.lastSeenAt, updatedAt: thread.updatedAt, branch: thread.branch, worktreePath: thread.worktreePath, @@ -347,6 +351,8 @@ function mapThreadShell( createdAt: thread.createdAt, archivedAt: thread.archivedAt, pinnedAt: thread.pinnedAt, + doneOverride: thread.doneOverride, + lastSeenAt: thread.lastSeenAt, updatedAt: thread.updatedAt, latestTurn: thread.latestTurn, branch: thread.branch, @@ -380,6 +386,8 @@ function toThreadShell(thread: Thread): ThreadShell { createdAt: thread.createdAt, archivedAt: thread.archivedAt, pinnedAt: thread.pinnedAt, + doneOverride: thread.doneOverride, + lastSeenAt: thread.lastSeenAt, updatedAt: thread.updatedAt, branch: thread.branch, worktreePath: thread.worktreePath, @@ -441,6 +449,8 @@ function toSidebarThreadSummary( createdAt: thread.createdAt, archivedAt: thread.archivedAt, pinnedAt: thread.pinnedAt, + doneOverride: thread.doneOverride, + lastSeenAt: thread.lastSeenAt, updatedAt: thread.updatedAt, latestTurn: thread.latestTurn, branch: thread.branch, @@ -505,6 +515,15 @@ function threadSessionsEqual( ); } +function doneOverridesEqual( + left: SidebarThreadSummary["doneOverride"], + right: SidebarThreadSummary["doneOverride"], +): boolean { + if (left === right) return true; + if (left === null || right === null) return false; + return left.state === right.state && left.at === right.at; +} + function threadDiffStatsEqual( left: SidebarThreadSummary["cumulativeDiffStat"], right: SidebarThreadSummary["cumulativeDiffStat"], @@ -528,6 +547,8 @@ function sidebarThreadSummariesEqual( left.createdAt === right.createdAt && left.archivedAt === right.archivedAt && left.pinnedAt === right.pinnedAt && + doneOverridesEqual(left.doneOverride, right.doneOverride) && + left.lastSeenAt === right.lastSeenAt && left.updatedAt === right.updatedAt && latestTurnsEqual(left.latestTurn, right.latestTurn) && left.branch === right.branch && @@ -556,6 +577,8 @@ function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): b left.createdAt === right.createdAt && left.archivedAt === right.archivedAt && left.pinnedAt === right.pinnedAt && + doneOverridesEqual(left.doneOverride, right.doneOverride) && + left.lastSeenAt === right.lastSeenAt && left.updatedAt === right.updatedAt && left.branch === right.branch && left.worktreePath === right.worktreePath && @@ -1504,6 +1527,8 @@ function applyEnvironmentOrchestrationEvent( updatedAt: event.payload.updatedAt, archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, deletedAt: null, messages: [], proposedPlans: [], @@ -1548,6 +1573,20 @@ function applyEnvironmentOrchestrationEvent( updatedAt: event.payload.updatedAt, })); + // Neither event moves `updatedAt`: filing or reading a thread is not work + // on it, and the inbox weighs both stamps against real activity. + case "thread.done-override-set": + return updateThreadState(state, event.payload.threadId, (thread) => ({ + ...thread, + doneOverride: { state: event.payload.state, at: event.payload.at }, + })); + + case "thread.seen-set": + return updateThreadState(state, event.payload.threadId, (thread) => ({ + ...thread, + lastSeenAt: event.payload.at, + })); + case "thread.meta-updated": return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, diff --git a/apps/web/src/threadAutoArchive.test.ts b/apps/web/src/threadAutoArchive.test.ts index 7b69a548..2d6e3c59 100644 --- a/apps/web/src/threadAutoArchive.test.ts +++ b/apps/web/src/threadAutoArchive.test.ts @@ -28,6 +28,8 @@ function thread(id: string, overrides: Partial = {}): Side createdAt: daysAgo(45), archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, updatedAt: daysAgo(45), latestTurn: null, branch: null, diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index bbe517f3..fdd7aeef 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -9,6 +9,7 @@ import type { OrchestrationSessionStatus, OrchestrationThreadActivity, OrchestrationThreadDiffStat, + OrchestrationThreadDoneOverride, OrchestrationThreadGoal, ProjectKind, ProjectScript as ContractProjectScript, @@ -126,6 +127,10 @@ export interface Thread { createdAt: string; archivedAt: string | null; pinnedAt: string | null; + /** See ThreadShell.doneOverride. */ + doneOverride: OrchestrationThreadDoneOverride | null; + /** See ThreadShell.lastSeenAt. */ + lastSeenAt: string | null; updatedAt?: string | undefined; latestTurn: OrchestrationLatestTurn | null; pendingSourceProposedPlan?: OrchestrationLatestTurn["sourceProposedPlan"]; @@ -165,6 +170,13 @@ export interface ThreadShell { createdAt: string; archivedAt: string | null; pinnedAt: string | null; + /** + * The user's last explicit Mark done / Reopen, held on the server so every + * device agrees on the inbox's Active/Wrapped split. Null when never filed. + */ + doneOverride: OrchestrationThreadDoneOverride | null; + /** When the user last saw this thread, server-held. Null until first seen. */ + lastSeenAt: string | null; updatedAt?: string | undefined; branch: string | null; worktreePath: string | null; @@ -193,6 +205,10 @@ export interface SidebarThreadSummary { createdAt: string; archivedAt: string | null; pinnedAt: string | null; + /** See ThreadShell.doneOverride. */ + doneOverride: OrchestrationThreadDoneOverride | null; + /** See ThreadShell.lastSeenAt. */ + lastSeenAt: string | null; updatedAt?: string | undefined; latestTurn: OrchestrationLatestTurn | null; branch: string | null; diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index ebc2191e..1f7f8d81 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -3,13 +3,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { clearThreadUi, + dropLegacyInboxState, hydratePersistedProjectState, - markThreadVisited, - markThreadUnread, PERSISTED_STATE_KEY, type PersistedUiState, persistState, + readLegacyInboxState, reorderProjects, + resolveSeenOverlay, setDefaultAdvertisedEndpointKey, setLastChatThreadRef, setProjectExpanded, @@ -23,9 +24,10 @@ function makeUiState(overrides: Partial = {}): UiState { return { projectExpandedById: {}, projectOrder: [], - threadLastVisitedAtById: {}, + seenThreadOverlays: {}, + threadSeedVisitedAtById: {}, threadChangedFilesExpandedById: {}, - doneThreadOverrides: {}, + doneThreadOverlays: {}, inboxProjectScopeKey: null, defaultAdvertisedEndpointKey: null, lastChatThreadRef: null, @@ -34,53 +36,35 @@ function makeUiState(overrides: Partial = {}): UiState { } describe("uiStateStore pure functions", () => { - it("markThreadVisited stores the provided server timestamp", () => { + it("a settled overlay retires once the server value moves off its baseline", () => { const threadId = ThreadId.make("thread-1"); - const initialState = makeUiState(); - - const next = markThreadVisited(initialState, threadId, "2026-02-25T12:30:00.700Z"); - - expect(next.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:30:00.700Z"); - }); - - it("markThreadVisited does not move visit state backwards under clock skew", () => { - const threadId = ThreadId.make("thread-1"); - const initialState = makeUiState({ - threadLastVisitedAtById: { - [threadId]: "2026-02-25T12:30:00.700Z", + const seen = makeUiState({ + seenThreadOverlays: { + [threadId]: { at: "2026-02-25T12:30:00.700Z", baselineAt: null, settled: false }, }, }); - const next = markThreadVisited(initialState, threadId, "2026-02-25T12:30:00.000Z"); - - expect(next).toBe(initialState); - }); - - it("markThreadUnread moves lastVisitedAt before completion for a completed thread", () => { - const threadId = ThreadId.make("thread-1"); - const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z"; - const initialState = makeUiState({ - threadLastVisitedAtById: { - [threadId]: "2026-02-25T12:35:00.000Z", - }, - }); + const confirmed = resolveSeenOverlay(seen, threadId, "2026-02-25T12:30:00.700Z", "confirmed"); + const stillPending = syncThreads(confirmed, [{ key: threadId, serverLastSeenAt: null }]); + expect(stillPending.seenThreadOverlays[threadId]?.at).toBe("2026-02-25T12:30:00.700Z"); - const next = markThreadUnread(initialState, threadId, latestTurnCompletedAt); - - expect(next.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:29:59.999Z"); + const retired = syncThreads(confirmed, [ + { key: threadId, serverLastSeenAt: "2026-02-25T12:30:00.700Z" }, + ]); + expect(retired.seenThreadOverlays).toEqual({}); }); - it("markThreadUnread does not change a thread without a completed turn", () => { + it("a failed overlay write is dropped so the row cannot diverge from the server", () => { const threadId = ThreadId.make("thread-1"); - const initialState = makeUiState({ - threadLastVisitedAtById: { - [threadId]: "2026-02-25T12:35:00.000Z", + const seen = makeUiState({ + seenThreadOverlays: { + [threadId]: { at: "2026-02-25T12:30:00.700Z", baselineAt: null, settled: false }, }, }); - const next = markThreadUnread(initialState, threadId, null); + const next = resolveSeenOverlay(seen, threadId, "2026-02-25T12:30:00.700Z", "failed"); - expect(next).toBe(initialState); + expect(next.seenThreadOverlays).toEqual({}); }); it("reorderProjects moves a project to a target index", () => { @@ -358,7 +342,7 @@ describe("uiStateStore pure functions", () => { const thread1 = ThreadId.make("thread-1"); const thread2 = ThreadId.make("thread-2"); const initialState = makeUiState({ - threadLastVisitedAtById: { + threadSeedVisitedAtById: { [thread1]: "2026-02-25T12:35:00.000Z", [thread2]: "2026-02-25T12:36:00.000Z", }, @@ -374,7 +358,7 @@ describe("uiStateStore pure functions", () => { const next = syncThreads(initialState, [{ key: thread1 }]); - expect(next.threadLastVisitedAtById).toEqual({ + expect(next.threadSeedVisitedAtById).toEqual({ [thread1]: "2026-02-25T12:35:00.000Z", }); expect(next.threadChangedFilesExpandedById).toEqual({ @@ -395,7 +379,7 @@ describe("uiStateStore pure functions", () => { }, ]); - expect(next.threadLastVisitedAtById).toEqual({ + expect(next.threadSeedVisitedAtById).toEqual({ [thread1]: "2026-02-25T12:35:00.000Z", }); }); @@ -418,7 +402,7 @@ describe("uiStateStore pure functions", () => { it("clearThreadUi removes visit state for deleted threads", () => { const thread1 = ThreadId.make("thread-1"); const initialState = makeUiState({ - threadLastVisitedAtById: { + threadSeedVisitedAtById: { [thread1]: "2026-02-25T12:35:00.000Z", }, threadChangedFilesExpandedById: { @@ -430,7 +414,7 @@ describe("uiStateStore pure functions", () => { const next = clearThreadUi(initialState, thread1); - expect(next.threadLastVisitedAtById).toEqual({}); + expect(next.threadSeedVisitedAtById).toEqual({}); expect(next.threadChangedFilesExpandedById).toEqual({}); }); @@ -526,6 +510,36 @@ describe("uiStateStore persistence round-trip", () => { vi.unstubAllGlobals(); }); + it("reads the legacy persisted inbox state and drops migrated keys", () => { + const threadId = ThreadId.make("thread-1"); + const otherThreadId = ThreadId.make("thread-2"); + window.localStorage.setItem( + PERSISTED_STATE_KEY, + JSON.stringify({ + doneThreadOverrides: { + [threadId]: { state: "done", at: "2026-02-25T12:30:00.000Z" }, + [otherThreadId]: { state: "active", at: "2026-02-25T12:31:00.000Z" }, + }, + threadLastVisitedAtById: { [threadId]: "2026-02-25T12:32:00.000Z" }, + } satisfies PersistedUiState), + ); + + const legacy = readLegacyInboxState(); + expect(legacy.doneThreadOverrides[threadId]).toEqual({ + state: "done", + at: "2026-02-25T12:30:00.000Z", + }); + expect(legacy.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:32:00.000Z"); + + dropLegacyInboxState([threadId]); + + const remaining = readLegacyInboxState(); + expect(remaining.doneThreadOverrides).toEqual({ + [otherThreadId]: { state: "active", at: "2026-02-25T12:31:00.000Z" }, + }); + expect(remaining.threadLastVisitedAtById).toEqual({}); + }); + it("preserves all-collapsed project state across restart", () => { // Regression: pre-fix, persistState only wrote `expandedProjectCwds`, so // an empty array on rehydrate was indistinguishable from a fresh install diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index af209ef1..322befa7 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -23,7 +23,14 @@ export interface PersistedUiState { projectOrderCwds?: string[]; defaultAdvertisedEndpointKey?: string | null; threadChangedFilesExpandedById?: Record>; + /** + * Legacy: inbox lifecycle state lived here before it moved onto the thread + * on the server, which is why a phone and a desktop disagreed about the + * Active/Wrapped split. Read once for migration, never written again. + */ doneThreadOverrides?: Record; + /** Legacy, see `doneThreadOverrides`. */ + threadLastVisitedAtById?: Record; inboxProjectScopeKey?: string | null; } @@ -32,18 +39,45 @@ export interface UiProjectState { projectOrder: string[]; } +/** + * An unconfirmed local write to server-held thread lifecycle state. It shows + * instantly, then retires: `baselineAt` is the server value at the moment the + * write was made, so once the server moves off that baseline the write has + * either landed or been superseded, and the overlay stops speaking either way. + * Nothing here survives a reload -- the server holds the truth. + */ +export interface ThreadOverlayWrite { + readonly at: string; + readonly baselineAt: string | null; + /** True once the dispatch resolved; only settled overlays are retired. */ + readonly settled: boolean; +} + +export interface ThreadDoneOverlayWrite extends ThreadOverlayWrite { + readonly state: "done" | "active"; +} + export interface UiThreadState { - threadLastVisitedAtById: Record; + /** Unconfirmed local "seen" writes, by scoped thread key. */ + seenThreadOverlays: Record; + /** + * Seen-as-of stamp for threads the server has no visit recorded for, + * captured the first time this device sees the thread so a backlog does not + * arrive all-unread. Frozen once set; any server value supersedes it. + */ + threadSeedVisitedAtById: Record; threadChangedFilesExpandedById: Record>; } export interface UiInboxState { /** - * The user's explicit word on each thread's lifecycle, by scoped thread - * key. An override never decides on its own -- isThreadDone resolves it - * against the thread's live state, so activity blockers always win. + * Unconfirmed local Mark done / Reopen writes, by scoped thread key. The + * confirmed word lives on the thread itself (`doneOverride`) so every device + * sees it; this only covers the gap until the server echoes the write back. + * An override never decides on its own -- isThreadDone resolves it against + * the thread's live state, so activity blockers always win. */ - doneThreadOverrides: Record; + doneThreadOverlays: Record; /** Which project chip is selected; null is All. */ inboxProjectScopeKey: string | null; } @@ -70,14 +104,18 @@ export interface SyncProjectInput { export interface SyncThreadInput { key: string; seedVisitedAt?: string | undefined; + /** Current server-held stamps, used to retire settled overlays. */ + serverLastSeenAt?: string | null | undefined; + serverDoneOverrideAt?: string | null | undefined; } const initialState: UiState = { projectExpandedById: {}, projectOrder: [], - threadLastVisitedAtById: {}, + seenThreadOverlays: {}, + threadSeedVisitedAtById: {}, threadChangedFilesExpandedById: {}, - doneThreadOverrides: {}, + doneThreadOverlays: {}, inboxProjectScopeKey: null, defaultAdvertisedEndpointKey: null, lastChatThreadRef: null, @@ -125,7 +163,6 @@ function readPersistedState(): UiState { threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded( parsed.threadChangedFilesExpandedById, ), - doneThreadOverrides: sanitizePersistedDoneOverrides(parsed.doneThreadOverrides), inboxProjectScopeKey: typeof parsed.inboxProjectScopeKey === "string" && parsed.inboxProjectScopeKey.length > 0 ? parsed.inboxProjectScopeKey @@ -136,6 +173,91 @@ function readPersistedState(): UiState { } } +/** + * The inbox lifecycle state a pre-sync build left in localStorage. Returned + * for one-time migration onto the server; see `migrateLegacyInboxState`. + */ +export function readLegacyInboxState(): { + doneThreadOverrides: Record; + threadLastVisitedAtById: Record; +} { + if (typeof window === "undefined") { + return { doneThreadOverrides: {}, threadLastVisitedAtById: {} }; + } + try { + const raw = window.localStorage.getItem(PERSISTED_STATE_KEY); + if (!raw) { + return { doneThreadOverrides: {}, threadLastVisitedAtById: {} }; + } + const parsed = JSON.parse(raw) as PersistedUiState; + return { + doneThreadOverrides: sanitizePersistedDoneOverrides(parsed.doneThreadOverrides), + threadLastVisitedAtById: sanitizePersistedVisitedAt(parsed.threadLastVisitedAtById), + }; + } catch { + return { doneThreadOverrides: {}, threadLastVisitedAtById: {} }; + } +} + +/** + * Drop the given threads' legacy inbox entries once the server holds them. + * Scoped to specific keys because the legacy blob spans every environment and + * migration runs per environment -- clearing it wholesale would throw away + * entries belonging to an environment that has not connected yet. + */ +export function dropLegacyInboxState(threadKeys: readonly string[]): void { + if (typeof window === "undefined" || threadKeys.length === 0) { + return; + } + try { + const raw = window.localStorage.getItem(PERSISTED_STATE_KEY); + if (!raw) { + return; + } + const parsed = JSON.parse(raw) as PersistedUiState; + const dropped = new Set(threadKeys); + const pruneRecord = (record: Record | undefined) => { + if (record === undefined || record === null || typeof record !== "object") { + return undefined; + } + const next = Object.fromEntries( + Object.entries(record).filter(([threadKey]) => !dropped.has(threadKey)), + ); + return Object.keys(next).length > 0 ? next : undefined; + }; + const nextDoneThreadOverrides = pruneRecord(parsed.doneThreadOverrides); + const nextThreadLastVisitedAtById = pruneRecord(parsed.threadLastVisitedAtById); + const { doneThreadOverrides: _done, threadLastVisitedAtById: _visited, ...rest } = parsed; + window.localStorage.setItem( + PERSISTED_STATE_KEY, + JSON.stringify({ + ...rest, + ...(nextDoneThreadOverrides ? { doneThreadOverrides: nextDoneThreadOverrides } : {}), + ...(nextThreadLastVisitedAtById + ? { threadLastVisitedAtById: nextThreadLastVisitedAtById } + : {}), + } satisfies PersistedUiState), + ); + } catch { + // Ignore quota/storage errors to avoid breaking chat UX. + } +} + +function sanitizePersistedVisitedAt( + value: PersistedUiState["threadLastVisitedAtById"], +): Record { + if (value === undefined || value === null || typeof value !== "object") { + return {}; + } + const sanitized: Record = {}; + for (const [key, at] of Object.entries(value)) { + if (typeof key === "string" && key.length > 0 && typeof at === "string" && at.length > 0) { + sanitized[key] = at; + } + } + return sanitized; +} + function sanitizePersistedDoneOverrides( value: PersistedUiState["doneThreadOverrides"], ): Record { @@ -242,7 +364,6 @@ export function persistState(state: UiState): void { projectOrderCwds, defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, threadChangedFilesExpandedById, - doneThreadOverrides: state.doneThreadOverrides, inboxProjectScopeKey: state.inboxProjectScopeKey, } satisfies PersistedUiState), ); @@ -445,29 +566,67 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput }; } +/** + * Whether a settled overlay has done its job. The write is only made when it + * would change what the server holds, so the server value moving off the + * baseline means the write either landed or was overtaken by another device; + * either way the server is now the better answer. + */ +function shouldRetireOverlay( + overlay: ThreadOverlayWrite, + serverAt: string | null | undefined, +): boolean { + return overlay.settled && (serverAt ?? null) !== overlay.baselineAt; +} + export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]): UiState { - const retainedThreadIds = new Set(threads.map((thread) => thread.key)); - const nextThreadLastVisitedAtById = Object.fromEntries( - Object.entries(state.threadLastVisitedAtById).filter(([threadId]) => - retainedThreadIds.has(threadId), + const retainedThreadKeys = new Set(threads.map((thread) => thread.key)); + + const nextSeedVisitedAtById = Object.fromEntries( + Object.entries(state.threadSeedVisitedAtById).filter(([threadKey]) => + retainedThreadKeys.has(threadKey), ), ); for (const thread of threads) { if ( - nextThreadLastVisitedAtById[thread.key] === undefined && + nextSeedVisitedAtById[thread.key] === undefined && thread.seedVisitedAt !== undefined && thread.seedVisitedAt.length > 0 ) { - nextThreadLastVisitedAtById[thread.key] = thread.seedVisitedAt; + nextSeedVisitedAtById[thread.key] = thread.seedVisitedAt; } } + + const serverSeenAtByKey = new Map( + threads.map((thread) => [thread.key, thread.serverLastSeenAt ?? null] as const), + ); + const serverDoneAtByKey = new Map( + threads.map((thread) => [thread.key, thread.serverDoneOverrideAt ?? null] as const), + ); + const nextSeenThreadOverlays = Object.fromEntries( + Object.entries(state.seenThreadOverlays).filter( + ([threadKey, overlay]) => + retainedThreadKeys.has(threadKey) && + !shouldRetireOverlay(overlay, serverSeenAtByKey.get(threadKey)), + ), + ); + const nextDoneThreadOverlays = Object.fromEntries( + Object.entries(state.doneThreadOverlays).filter( + ([threadKey, overlay]) => + retainedThreadKeys.has(threadKey) && + !shouldRetireOverlay(overlay, serverDoneAtByKey.get(threadKey)), + ), + ); + const nextThreadChangedFilesExpandedById = Object.fromEntries( - Object.entries(state.threadChangedFilesExpandedById).filter(([threadId]) => - retainedThreadIds.has(threadId), + Object.entries(state.threadChangedFilesExpandedById).filter(([threadKey]) => + retainedThreadKeys.has(threadKey), ), ); if ( - recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById) && + recordsEqual(state.threadSeedVisitedAtById, nextSeedVisitedAtById) && + recordsEqual(state.seenThreadOverlays, nextSeenThreadOverlays) && + recordsEqual(state.doneThreadOverlays, nextDoneThreadOverlays) && nestedBooleanRecordsEqual( state.threadChangedFilesExpandedById, nextThreadChangedFilesExpandedById, @@ -477,90 +636,106 @@ export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]) } return { ...state, - threadLastVisitedAtById: nextThreadLastVisitedAtById, + threadSeedVisitedAtById: nextSeedVisitedAtById, + seenThreadOverlays: nextSeenThreadOverlays, + doneThreadOverlays: nextDoneThreadOverlays, threadChangedFilesExpandedById: nextThreadChangedFilesExpandedById, }; } /** - * Stamp the user's word on a thread's lifecycle. Pure and idempotent: marking - * a done thread done again refreshes nothing, so a double-click cannot move a - * row twice. + * Show the user's word on a thread's lifecycle before the server has echoed + * it back. The stamp is the caller's, because the same stamp is sent to the + * server -- overlay and confirmed value must agree or the row would move + * twice. */ -export function setDoneOverride( +export function setDoneOverlay( state: UiState, threadKey: string, - override: { state: "done" | "active"; at: string }, + overlay: ThreadDoneOverlayWrite, ): UiState { - const current = state.doneThreadOverrides[threadKey]; - if (current !== undefined && current.state === override.state) { - return state; - } return { ...state, - doneThreadOverrides: { ...state.doneThreadOverrides, [threadKey]: override }, + doneThreadOverlays: { ...state.doneThreadOverlays, [threadKey]: overlay }, }; } -export function markThreadVisited(state: UiState, threadId: string, visitedAt?: string): UiState { - const at = visitedAt ?? new Date().toISOString(); - const visitedAtMs = Date.parse(at); - const previousVisitedAt = state.threadLastVisitedAtById[threadId]; - const previousVisitedAtMs = previousVisitedAt ? Date.parse(previousVisitedAt) : NaN; - if ( - Number.isFinite(previousVisitedAtMs) && - Number.isFinite(visitedAtMs) && - previousVisitedAtMs >= visitedAtMs - ) { - return state; - } +export function setSeenOverlay( + state: UiState, + threadKey: string, + overlay: ThreadOverlayWrite, +): UiState { return { ...state, - threadLastVisitedAtById: { - ...state.threadLastVisitedAtById, - [threadId]: at, - }, + seenThreadOverlays: { ...state.seenThreadOverlays, [threadKey]: overlay }, }; } -export function markThreadUnread( +/** + * Close out a dispatched overlay write. A confirmed write stands until the + * server value moves off its baseline; a failed one is dropped immediately, + * so a dead connection can never strand the row in a state the server has + * never heard of. + */ +export function resolveDoneOverlay( state: UiState, - threadId: string, - latestTurnCompletedAt: string | null | undefined, + threadKey: string, + at: string, + outcome: "confirmed" | "failed", ): UiState { - if (!latestTurnCompletedAt) { + const overlay = state.doneThreadOverlays[threadKey]; + if (overlay === undefined || overlay.at !== at) { return state; } - const latestTurnCompletedAtMs = Date.parse(latestTurnCompletedAt); - if (Number.isNaN(latestTurnCompletedAtMs)) { - return state; + const nextOverlays = { ...state.doneThreadOverlays }; + if (outcome === "failed") { + delete nextOverlays[threadKey]; + } else { + nextOverlays[threadKey] = { ...overlay, settled: true }; } - const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString(); - if (state.threadLastVisitedAtById[threadId] === unreadVisitedAt) { + return { ...state, doneThreadOverlays: nextOverlays }; +} + +export function resolveSeenOverlay( + state: UiState, + threadKey: string, + at: string, + outcome: "confirmed" | "failed", +): UiState { + const overlay = state.seenThreadOverlays[threadKey]; + if (overlay === undefined || overlay.at !== at) { return state; } - return { - ...state, - threadLastVisitedAtById: { - ...state.threadLastVisitedAtById, - [threadId]: unreadVisitedAt, - }, - }; + const nextOverlays = { ...state.seenThreadOverlays }; + if (outcome === "failed") { + delete nextOverlays[threadKey]; + } else { + nextOverlays[threadKey] = { ...overlay, settled: true }; + } + return { ...state, seenThreadOverlays: nextOverlays }; } -export function clearThreadUi(state: UiState, threadId: string): UiState { - const hasVisitedState = threadId in state.threadLastVisitedAtById; - const hasChangedFilesState = threadId in state.threadChangedFilesExpandedById; - if (!hasVisitedState && !hasChangedFilesState) { +export function clearThreadUi(state: UiState, threadKey: string): UiState { + const hasSeenOverlay = threadKey in state.seenThreadOverlays; + const hasDoneOverlay = threadKey in state.doneThreadOverlays; + const hasSeedState = threadKey in state.threadSeedVisitedAtById; + const hasChangedFilesState = threadKey in state.threadChangedFilesExpandedById; + if (!hasSeenOverlay && !hasDoneOverlay && !hasSeedState && !hasChangedFilesState) { return state; } - const nextThreadLastVisitedAtById = { ...state.threadLastVisitedAtById }; + const nextSeenThreadOverlays = { ...state.seenThreadOverlays }; + const nextDoneThreadOverlays = { ...state.doneThreadOverlays }; + const nextSeedVisitedAtById = { ...state.threadSeedVisitedAtById }; const nextThreadChangedFilesExpandedById = { ...state.threadChangedFilesExpandedById }; - delete nextThreadLastVisitedAtById[threadId]; - delete nextThreadChangedFilesExpandedById[threadId]; + delete nextSeenThreadOverlays[threadKey]; + delete nextDoneThreadOverlays[threadKey]; + delete nextSeedVisitedAtById[threadKey]; + delete nextThreadChangedFilesExpandedById[threadKey]; return { ...state, - threadLastVisitedAtById: nextThreadLastVisitedAtById, + seenThreadOverlays: nextSeenThreadOverlays, + doneThreadOverlays: nextDoneThreadOverlays, + threadSeedVisitedAtById: nextSeedVisitedAtById, threadChangedFilesExpandedById: nextThreadChangedFilesExpandedById, }; } @@ -710,12 +885,12 @@ export function reorderProjects( interface UiStateStore extends UiState { syncProjects: (projects: readonly SyncProjectInput[]) => void; syncThreads: (threads: readonly SyncThreadInput[]) => void; - markThreadDone: (threadKey: string, at: string) => void; - reopenThread: (threadKey: string, at: string) => void; + setDoneOverlay: (threadKey: string, overlay: ThreadDoneOverlayWrite) => void; + setSeenOverlay: (threadKey: string, overlay: ThreadOverlayWrite) => void; + resolveDoneOverlay: (threadKey: string, at: string, outcome: "confirmed" | "failed") => void; + resolveSeenOverlay: (threadKey: string, at: string, outcome: "confirmed" | "failed") => void; setInboxProjectScope: (projectKey: string | null) => void; - markThreadVisited: (threadId: string, visitedAt?: string) => void; - markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; - clearThreadUi: (threadId: string) => void; + clearThreadUi: (threadKey: string) => void; setThreadChangedFilesExpanded: ( threadId: string, turnId: string, @@ -736,21 +911,19 @@ export const useUiStateStore = create((set) => ({ ...readPersistedState(), syncProjects: (projects) => set((state) => syncProjects(state, projects)), syncThreads: (threads) => set((state) => syncThreads(state, threads)), - markThreadDone: (threadKey, at) => - set((state) => setDoneOverride(state, threadKey, { state: "done", at })), - reopenThread: (threadKey, at) => - set((state) => setDoneOverride(state, threadKey, { state: "active", at })), + setDoneOverlay: (threadKey, overlay) => set((state) => setDoneOverlay(state, threadKey, overlay)), + setSeenOverlay: (threadKey, overlay) => set((state) => setSeenOverlay(state, threadKey, overlay)), + resolveDoneOverlay: (threadKey, at, outcome) => + set((state) => resolveDoneOverlay(state, threadKey, at, outcome)), + resolveSeenOverlay: (threadKey, at, outcome) => + set((state) => resolveSeenOverlay(state, threadKey, at, outcome)), setInboxProjectScope: (projectKey) => set((state) => state.inboxProjectScopeKey === projectKey ? state : { ...state, inboxProjectScopeKey: projectKey }, ), - markThreadVisited: (threadId, visitedAt) => - set((state) => markThreadVisited(state, threadId, visitedAt)), - markThreadUnread: (threadId, latestTurnCompletedAt) => - set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)), - clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)), + clearThreadUi: (threadKey) => set((state) => clearThreadUi(state, threadKey)), setThreadChangedFilesExpanded: (threadId, turnId, expanded, defaultExpanded) => set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded, defaultExpanded), diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 741ef207..b574f590 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -28,6 +28,8 @@ function makeThread(overrides: Partial = {}): Thread { createdAt: "2026-02-13T00:00:00.000Z", archivedAt: null, pinnedAt: null, + doneOverride: null, + lastSeenAt: null, latestTurn: null, branch: null, worktreePath: null, diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index a7eee17d..82dc7f9e 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -510,6 +510,20 @@ export const OrchestrationThreadGoal = Schema.Struct({ }); export type OrchestrationThreadGoal = typeof OrchestrationThreadGoal.Type; +export const ThreadDoneOverrideState = Schema.Literals(["done", "active"]); +export type ThreadDoneOverrideState = typeof ThreadDoneOverrideState.Type; + +/** + * The user's explicit word on where a thread belongs in the inbox, stamped + * when given. Server-held so every device agrees on the Active/Wrapped split; + * the freshness rule that weighs it against real activity stays client-side. + */ +export const OrchestrationThreadDoneOverride = Schema.Struct({ + state: ThreadDoneOverrideState, + at: IsoDateTime, +}); +export type OrchestrationThreadDoneOverride = typeof OrchestrationThreadDoneOverride.Type; + export const OrchestrationCheckpointFile = Schema.Struct({ path: TrimmedNonEmptyString, kind: TrimmedNonEmptyString, @@ -613,6 +627,12 @@ export const OrchestrationThread = Schema.Struct({ updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), pinnedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + /** See OrchestrationThreadShell.doneOverride. */ + doneOverride: Schema.NullOr(OrchestrationThreadDoneOverride).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + /** See OrchestrationThreadShell.lastSeenAt. */ + lastSeenAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), deletedAt: Schema.NullOr(IsoDateTime), messages: Schema.Array(OrchestrationMessage), proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( @@ -671,6 +691,21 @@ export const OrchestrationThreadShell = Schema.Struct({ updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), pinnedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + /** + * The user's last explicit Mark done / Reopen for this thread, or null if + * they never gave one. Deliberately does not move `updatedAt`: the inbox + * weighs this stamp against real work activity, so filing a thread must not + * count as activity. + */ + doneOverride: Schema.NullOr(OrchestrationThreadDoneOverride).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + /** + * When the user last saw this thread, last-write-wins across devices. Moves + * backwards on purpose when a thread is marked unread. Null until the first + * visit is recorded; also does not move `updatedAt`. + */ + lastSeenAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), session: Schema.NullOr(OrchestrationSession), latestUserMessageAt: Schema.NullOr(IsoDateTime), hasPendingApprovals: Schema.Boolean, @@ -883,6 +918,32 @@ const ThreadUnpinCommand = Schema.Struct({ threadId: ThreadId, }); +/** + * Record the user's explicit inbox filing for a thread. `at` is the client's + * stamp and is stored verbatim: the inbox compares it against the thread's + * activity to decide whether the word still stands, so a server clock would + * make an override look fresher than the work that preceded it. + */ +const ThreadDoneOverrideSetCommand = Schema.Struct({ + type: Schema.Literal("thread.done-override.set"), + commandId: CommandId, + threadId: ThreadId, + state: ThreadDoneOverrideState, + at: IsoDateTime, +}); + +/** + * Record when the user last saw a thread. Last-write-wins; backwards stamps + * are legal because "mark unread" sets seen to just before the completion it + * is un-seeing. + */ +const ThreadSeenSetCommand = Schema.Struct({ + type: Schema.Literal("thread.seen.set"), + commandId: CommandId, + threadId: ThreadId, + at: IsoDateTime, +}); + const ThreadMetaUpdateCommand = Schema.Struct({ type: Schema.Literal("thread.meta.update"), commandId: CommandId, @@ -1130,6 +1191,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadUnarchiveCommand, ThreadPinCommand, ThreadUnpinCommand, + ThreadDoneOverrideSetCommand, + ThreadSeenSetCommand, ThreadMetaUpdateCommand, ThreadForkCommand, ThreadRuntimeModeSetCommand, @@ -1162,6 +1225,8 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadUnarchiveCommand, ThreadPinCommand, ThreadUnpinCommand, + ThreadDoneOverrideSetCommand, + ThreadSeenSetCommand, ThreadMetaUpdateCommand, ClientThreadForkCommand, ThreadRuntimeModeSetCommand, @@ -1345,6 +1410,8 @@ export const OrchestrationEventType = Schema.Literals([ "thread.unarchived", "thread.pinned", "thread.unpinned", + "thread.done-override-set", + "thread.seen-set", "thread.meta-updated", "thread.runtime-mode-set", "thread.interaction-mode-set", @@ -1447,6 +1514,19 @@ export const ThreadUnpinnedPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +/** No `updatedAt`: filing a thread is not work on it. */ +export const ThreadDoneOverrideSetPayload = Schema.Struct({ + threadId: ThreadId, + state: ThreadDoneOverrideState, + at: IsoDateTime, +}); + +/** No `updatedAt`: reading a thread is not work on it. */ +export const ThreadSeenSetPayload = Schema.Struct({ + threadId: ThreadId, + at: IsoDateTime, +}); + export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), @@ -1695,6 +1775,16 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.unpinned"), payload: ThreadUnpinnedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.done-override-set"), + payload: ThreadDoneOverrideSetPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.seen-set"), + payload: ThreadSeenSetPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.meta-updated"),