diff --git a/UPSTREAM_DIVERGENCE.md b/UPSTREAM_DIVERGENCE.md index 430b241d0793..af25000482bb 100644 --- a/UPSTREAM_DIVERGENCE.md +++ b/UPSTREAM_DIVERGENCE.md @@ -88,13 +88,23 @@ Introduced this doc. No upstream PR; meta-work that codifies the "Already equiva New SHA: `ee646a654`. -### PR #69 — sidebar thread-row timestamp +### PR #69 — sidebar row timestamp (part 1 of upstream #1996) -| Upstream | Subject | New SHA | -| ------------------------------------------------------ | -------------------------------------------------- | ----------- | -| [#1996](https://github.com/pingdotgg/t3code/pull/1996) | Use latest user message time for thread timestamps | `524e93afd` | +| Upstream | Subject | New SHA | +| ------------------------------------------------------ | -------------------------------------------------------- | ----------- | +| [#1996](https://github.com/pingdotgg/t3code/pull/1996) | Use latest user message time for thread timestamps (row) | `524e93afd` | + +Narrow behavioral port: `Sidebar.tsx:715` thread-row label now falls back `thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt`. Matches the helper already in `CommandPalette.logic.ts:161`. Preserves MarCode's structural-sharing event handling ([FEATURES.md §"Incremental Event Handling"](./FEATURES.md#incremental-event-handling--structural-sharing)). + +### PR #70 — shell-stream authority for sidebar summary (part 2 of upstream #1996) + +| Upstream | Subject | New SHA | +| ------------------------------------------------------ | ------------------------------------------------------------------- | ----------- | +| [#1996](https://github.com/pingdotgg/t3code/pull/1996) | Use latest user message time for thread timestamps (store refactor) | `506b808c2` | + +**Correctness fix, not cleanup.** Removes the client-derived `buildSidebarThreadSummary` path in `store.ts` that was overwriting server-authoritative sidebar flags (`hasPendingApprovals`, `hasPendingUserInput`, `hasActionableProposedPlan`, `latestUserMessageAt`) during every detail-stream write. Matches the stream-separation contract MEMORY.md requires (guards against "ghost Pending Approval badges on resolved threads"). Tests in `store.test.ts` describe block "shell events are authoritative for sidebar summary flags" rewritten to assert the new contract: detail stream must not touch sidebar; shell stream is the sole writer. -Narrow port — persisted `latestUserMessageAt` drives `getThreadSortTimestamp` without rewriting `store.ts`. Preserves MarCode's structural-sharing event handling ([FEATURES.md §"Incremental Event Handling"](./FEATURES.md#incremental-event-handling--structural-sharing)). +What we deliberately did NOT port from upstream #1996: structural reorganization of `store.ts` (commentary, `ensureThreadRegistered` extraction, `retainThreadScopedRecord` changes). MarCode's Incremental Event Handling & Structural Sharing (FEATURES.md §1) already covers the functional behavior; upstream's cosmetic reshuffle is net risk for no gain. ### PR #71 — provider model selection option arrays (upstream #2246) @@ -123,21 +133,21 @@ Narrow port — persisted `latestUserMessageAt` drives `getThreadSortTimestamp` These upstream PRs are **behaviorally present** in MarCode via non-identical patches. `git cherry` flags them as missing because patch-ids differ. **Do not re-port.** -| Upstream | Subject | MarCode equivalent | -| ------------------------------------------------------ | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [#1541](https://github.com/pingdotgg/t3code/pull/1541) | fix(claude): emit plan events for TodoWrite during input streaming | `f4719a05f feat(tool-activity): TodoWrite → plan sidebar + apply_patch → file-change for Cursor/OpenCode` — generalized via `@marcode/shared/toolActivity` with `isTodoWriteTool` + `extractPlanStepsFromTodos` re-exported in `ClaudeAdapter.ts:48` and also used in Cursor / OpenCode adapters. | -| [#1944](https://github.com/pingdotgg/t3code/pull/1944) | feat(web): add tooltip to composer file mention pill | Already present in `ComposerPromptEditor.tsx` from earlier merge. Cherry-pick was an empty diff except for an unrelated `PasteCommandType` import differing. | -| [#1953](https://github.com/pingdotgg/t3code/pull/1953) | Migrate chat scrolling and branch lists to LegendList | `f3670b839 perf(chat): restore LegendList virtualization for message timeline` — restored after an earlier merge lost it. | -| [#1996](https://github.com/pingdotgg/t3code/pull/1996) | Use latest user message time for thread timestamps | **NOT yet equivalent** — listed in [Pending real work](#pending-real-work). Only `917ab971b Remove unnecessary export from getThreadSortTimestamp` exists locally but doesn't carry the substantive behavior. | -| [#2001](https://github.com/pingdotgg/t3code/pull/2001) | Warm sidebar thread detail subscriptions | `e82a9cf52 feat(sidebar): restore thread snapshot prewarming` — different implementation (snapshot prewarm in Sidebar), achieves same UX goal. **Per [MEMORY.md]**: do NOT add `retainThreadDetailSubscription` to `ChatView.tsx`. | -| [#2002](https://github.com/pingdotgg/t3code/pull/2002) | Fix thread timeline autoscroll and simplify branch state | `fb41df9bd feat(timeline): auto-follow bottom when content grows` — ResizeObserver-based MarCode implementation. | -| [#2024](https://github.com/pingdotgg/t3code/pull/2024) | Add filesystem browse API and command palette project picker | `b9ef378e9 feat: unify add-project/add-folder on Cmd+K filesystem picker (upstream #2024)` — explicit port. | -| [#2055](https://github.com/pingdotgg/t3code/pull/2055) | feat: configurable project grouping | `6673ca823 feat(sidebar): configurable project grouping (port upstream #2055)` — explicit port + `3dae9e63b chore(web): port path normalization helpers (prereq)`. | -| [#2072](https://github.com/pingdotgg/t3code/pull/2072) | feat: add Claude Opus 4.7 to built-in models | `788194665` + `4c32418a2` + `18216a500` + `10bdc1225` — full stack including default-effort tweaks. | -| [#2099](https://github.com/pingdotgg/t3code/pull/2099) | guard against missing sidebarProjectGroupingOverrides | `c5f4c9115 fix(sidebar): handle undefined sidebarProjectGroupingOverrides on stale settings` — stricter version of the same fix (handles raw JSON decode). | -| [#2153](https://github.com/pingdotgg/t3code/pull/2153) | Redesign model picker with favorites and search | `41ddce8f0 feat(model-picker): port upstream sexy redesign with favorites and search` — explicit port. | -| [#2192](https://github.com/pingdotgg/t3code/pull/2192) | fix(server): prevent probeClaudeCapabilities from wasting API requests | Already present: `waitForAbortSignal` + `SDKUserMessage` never-yielding prompt in `ClaudeProvider.ts:485,514`. Cherry-pick diff is empty against our HEAD. | -| [#2255](https://github.com/pingdotgg/t3code/pull/2255) | fix(server): restore CODEX_HOME tilde expansion for Codex launches | `expandHomePath` already wired on `CodexProvider.ts:226` and `CodexSessionRuntime.ts:688` via [#2210](https://github.com/pingdotgg/t3code/pull/2210) + follow-ups (`63ea04e29`, `42afbb226`). | +| Upstream | Subject | MarCode equivalent | +| ------------------------------------------------------ | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [#1541](https://github.com/pingdotgg/t3code/pull/1541) | fix(claude): emit plan events for TodoWrite during input streaming | `f4719a05f feat(tool-activity): TodoWrite → plan sidebar + apply_patch → file-change for Cursor/OpenCode` — generalized via `@marcode/shared/toolActivity` with `isTodoWriteTool` + `extractPlanStepsFromTodos` re-exported in `ClaudeAdapter.ts:48` and also used in Cursor / OpenCode adapters. | +| [#1944](https://github.com/pingdotgg/t3code/pull/1944) | feat(web): add tooltip to composer file mention pill | Already present in `ComposerPromptEditor.tsx` from earlier merge. Cherry-pick was an empty diff except for an unrelated `PasteCommandType` import differing. | +| [#1953](https://github.com/pingdotgg/t3code/pull/1953) | Migrate chat scrolling and branch lists to LegendList | `f3670b839 perf(chat): restore LegendList virtualization for message timeline` — restored after an earlier merge lost it. | +| [#1996](https://github.com/pingdotgg/t3code/pull/1996) | Use latest user message time for thread timestamps | Ported across two PRs in this cycle: `524e93afd feat(sidebar): latestUserMessageAt for thread row timestamp (upstream #1996)` (PR #69, behavioral) + `506b808c2 fix(store): enforce shell-stream authority for sidebar summary flags (upstream #1996)` (PR #70, correctness refactor — deletes the detail-stream-derived `buildSidebarThreadSummary`). The earlier `917ab971b Remove unnecessary export from getThreadSortTimestamp` was unrelated prep. | +| [#2001](https://github.com/pingdotgg/t3code/pull/2001) | Warm sidebar thread detail subscriptions | `e82a9cf52 feat(sidebar): restore thread snapshot prewarming` — different implementation (snapshot prewarm in Sidebar), achieves same UX goal. **Per [MEMORY.md]**: do NOT add `retainThreadDetailSubscription` to `ChatView.tsx`. | +| [#2002](https://github.com/pingdotgg/t3code/pull/2002) | Fix thread timeline autoscroll and simplify branch state | `fb41df9bd feat(timeline): auto-follow bottom when content grows` — ResizeObserver-based MarCode implementation. | +| [#2024](https://github.com/pingdotgg/t3code/pull/2024) | Add filesystem browse API and command palette project picker | `b9ef378e9 feat: unify add-project/add-folder on Cmd+K filesystem picker (upstream #2024)` — explicit port. | +| [#2055](https://github.com/pingdotgg/t3code/pull/2055) | feat: configurable project grouping | `6673ca823 feat(sidebar): configurable project grouping (port upstream #2055)` — explicit port + `3dae9e63b chore(web): port path normalization helpers (prereq)`. | +| [#2072](https://github.com/pingdotgg/t3code/pull/2072) | feat: add Claude Opus 4.7 to built-in models | `788194665` + `4c32418a2` + `18216a500` + `10bdc1225` — full stack including default-effort tweaks. | +| [#2099](https://github.com/pingdotgg/t3code/pull/2099) | guard against missing sidebarProjectGroupingOverrides | `c5f4c9115 fix(sidebar): handle undefined sidebarProjectGroupingOverrides on stale settings` — stricter version of the same fix (handles raw JSON decode). | +| [#2153](https://github.com/pingdotgg/t3code/pull/2153) | Redesign model picker with favorites and search | `41ddce8f0 feat(model-picker): port upstream sexy redesign with favorites and search` — explicit port. | +| [#2192](https://github.com/pingdotgg/t3code/pull/2192) | fix(server): prevent probeClaudeCapabilities from wasting API requests | Already present: `waitForAbortSignal` + `SDKUserMessage` never-yielding prompt in `ClaudeProvider.ts:485,514`. Cherry-pick diff is empty against our HEAD. | +| [#2255](https://github.com/pingdotgg/t3code/pull/2255) | fix(server): restore CODEX_HOME tilde expansion for Codex launches | `expandHomePath` already wired on `CodexProvider.ts:226` and `CodexSessionRuntime.ts:688` via [#2210](https://github.com/pingdotgg/t3code/pull/2210) + follow-ups (`63ea04e29`, `42afbb226`). | **Verification strategy for re-checking in a later cycle:** grep for the symbol the upstream PR adds. If it's already in MarCode, confirm; do not cherry-pick. @@ -177,7 +187,7 @@ MarCode ships semver alphas (`1.0.0-alpha.*`), not nightly builds. Adopting nigh ## Pending real work -_None as of 2026-04-24._ Both previously-listed rows (#1996 and #2246) landed in this cycle (see PR #69 and PR #71 above). Re-run the `git cherry origin/main upstream/main` workflow at the top of this doc when starting a new cycle to populate this section. +_None as of 2026-04-24._ Both previously-listed rows (#1996 and #2246) landed in this cycle — #1996 across PRs #69 + #70, #2246 via PR #71. Re-run the `git cherry origin/main upstream/main` workflow at the top of this doc when starting a new cycle to populate this section. --- diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 63ca8271501b..d0d6ec5c2051 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -1386,9 +1386,10 @@ describe("shell events are authoritative for sidebar summary flags", () => { }; } - it("clears hasPendingUserInput when a later shell event reports the projection resolved it", () => { + it("detail-stream activity-appended event does not populate hasPendingUserInput; shell stream is the sole writer", () => { const thread = makeThread(); const state = makeState(thread); + const ref = scopeThreadRef(thread.environmentId, thread.id); const afterActivity = applyOrchestrationEvent( state, @@ -1423,23 +1424,31 @@ describe("shell events are authoritative for sidebar summary flags", () => { localEnvironmentId, ); - const ref = scopeThreadRef(thread.environmentId, thread.id); - const afterActivitySummary = selectSidebarThreadSummaryByRef(afterActivity, ref); - expect(afterActivitySummary?.hasPendingUserInput).toBe(true); - expect(resolveThreadStatusPill({ thread: afterActivitySummary! })?.label).toBe( - "Awaiting Input", - ); + // Detail stream must NOT write sidebarThreadSummaryById — shell stream owns it. + expect(selectSidebarThreadSummaryByRef(afterActivity, ref)).toBeUndefined(); - const afterShell = applyShellEvent( + // A shell event that reports the server-computed flag as TRUE populates the summary. + const afterShellWithPending = applyShellEvent( afterActivity, - makeThreadUpsertedShellEvent(thread), + makeThreadUpsertedShellEvent(thread, { hasPendingUserInput: true }), localEnvironmentId, ); - const afterShellSummary = selectSidebarThreadSummaryByRef(afterShell, ref); - expect(afterShellSummary?.hasPendingUserInput).toBe(false); + const pendingSummary = selectSidebarThreadSummaryByRef(afterShellWithPending, ref); + expect(pendingSummary?.hasPendingUserInput).toBe(true); + expect(resolveThreadStatusPill({ thread: pendingSummary! })?.label).toBe("Awaiting Input"); + + // A later shell event with the flag cleared wins authoritatively. + const afterShellResolved = applyShellEvent( + afterShellWithPending, + makeThreadUpsertedShellEvent(thread, { hasPendingUserInput: false }), + localEnvironmentId, + ); + expect(selectSidebarThreadSummaryByRef(afterShellResolved, ref)?.hasPendingUserInput).toBe( + false, + ); }); - it("clears hasActionableProposedPlan when a later shell event reports the projection resolved it", () => { + it("detail-stream proposed-plan-upserted event does not populate hasActionableProposedPlan; shell stream is the sole writer", () => { const thread = makeThread({ interactionMode: "plan", latestTurn: { @@ -1452,6 +1461,7 @@ describe("shell events are authoritative for sidebar summary flags", () => { }, }); const state = makeState(thread); + const ref = scopeThreadRef(thread.environmentId, thread.id); const afterPlan = applyOrchestrationEvent( state, @@ -1470,17 +1480,27 @@ describe("shell events are authoritative for sidebar summary flags", () => { localEnvironmentId, ); - const ref = scopeThreadRef(thread.environmentId, thread.id); - const afterPlanSummary = selectSidebarThreadSummaryByRef(afterPlan, ref); - expect(afterPlanSummary?.hasActionableProposedPlan).toBe(true); - expect(resolveThreadStatusPill({ thread: afterPlanSummary! })?.label).toBe("Plan Ready"); + // Detail stream must NOT write sidebarThreadSummaryById — shell stream owns it. + expect(selectSidebarThreadSummaryByRef(afterPlan, ref)).toBeUndefined(); - const afterShell = applyShellEvent( + // A shell event that reports the server-computed flag as TRUE populates the summary. + const afterShellWithPlan = applyShellEvent( afterPlan, - makeThreadUpsertedShellEvent(thread), + makeThreadUpsertedShellEvent(thread, { hasActionableProposedPlan: true }), + localEnvironmentId, + ); + const planSummary = selectSidebarThreadSummaryByRef(afterShellWithPlan, ref); + expect(planSummary?.hasActionableProposedPlan).toBe(true); + expect(resolveThreadStatusPill({ thread: planSummary! })?.label).toBe("Plan Ready"); + + // A later shell event with the flag cleared wins authoritatively. + const afterShellResolved = applyShellEvent( + afterShellWithPlan, + makeThreadUpsertedShellEvent(thread, { hasActionableProposedPlan: false }), localEnvironmentId, ); - const afterShellSummary = selectSidebarThreadSummaryByRef(afterShell, ref); - expect(afterShellSummary?.hasActionableProposedPlan).toBe(false); + expect( + selectSidebarThreadSummaryByRef(afterShellResolved, ref)?.hasActionableProposedPlan, + ).toBe(false); }); }); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 3804cbf31c49..6a8f14707752 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -23,12 +23,6 @@ import type { import { Schema } from "effect"; import { resolveModelSlugForProvider } from "@marcode/shared/model"; import { create } from "zustand"; -import { - derivePendingApprovals, - derivePendingUserInputs, - findLatestProposedPlan, - hasActionableProposedPlan, -} from "./session-logic"; import { type ChatMessage, type Project, @@ -309,42 +303,6 @@ function toThreadTurnState(thread: Thread): ThreadTurnState { }; } -function getLatestUserMessageAt(messages: ReadonlyArray): string | null { - let latestUserMessageAt: string | null = null; - for (const message of messages) { - if (message.role !== "user") { - continue; - } - if (latestUserMessageAt === null || message.createdAt > latestUserMessageAt) { - latestUserMessageAt = message.createdAt; - } - } - return latestUserMessageAt; -} - -function buildSidebarThreadSummary(thread: Thread): SidebarThreadSummary { - return { - id: thread.id, - environmentId: thread.environmentId, - projectId: thread.projectId, - title: thread.title, - interactionMode: thread.interactionMode, - session: thread.session, - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - latestTurn: thread.latestTurn, - branch: thread.branch, - worktreePath: thread.worktreePath, - latestUserMessageAt: getLatestUserMessageAt(thread.messages), - hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, - hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, - hasActionableProposedPlan: hasActionableProposedPlan( - findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null), - ), - }; -} - function sidebarThreadSummariesEqual( left: SidebarThreadSummary | undefined, right: SidebarThreadSummary, @@ -486,6 +444,22 @@ function getThreads(state: EnvironmentState): Thread[] { }); } +/** + * Write thread state from the **detail stream** (single-thread subscription). + * + * Owns: messages, activities, proposedPlans, turn diff summaries. + * Also writes threadShellById / threadSessionById / threadTurnStateById so the + * active thread has up-to-date state even if the shell event hasn't arrived yet; + * structural equality checks below keep those writes from triggering re-renders + * when both streams deliver equivalent data. + * + * Does NOT write sidebarThreadSummaryById — that is shell-stream-only. The + * shell stream carries server-computed hasPendingApprovals / hasPendingUserInput / + * hasActionableProposedPlan / latestUserMessageAt fields; re-deriving them from + * `thread.activities` / `thread.messages` here would overwrite authoritative + * values and cause ghost "Pending Approval" badges on resolved threads (see + * MEMORY.md and upstream #1996). + */ function writeThreadState( state: EnvironmentState, nextThread: Thread, @@ -495,8 +469,6 @@ function writeThreadState( const nextTurnState = toThreadTurnState(nextThread); const previousShell = state.threadShellById[nextThread.id]; const previousTurnState = state.threadTurnStateById[nextThread.id]; - const previousSummary = state.sidebarThreadSummaryById[nextThread.id]; - const nextSummary = buildSidebarThreadSummary(nextThread); let nextState = state; @@ -630,16 +602,6 @@ function writeThreadState( }; } - if (!sidebarThreadSummariesEqual(previousSummary, nextSummary)) { - nextState = { - ...nextState, - sidebarThreadSummaryById: { - ...nextState.sidebarThreadSummaryById, - [nextThread.id]: nextSummary, - }, - }; - } - return nextState; } @@ -747,6 +709,23 @@ function ensureThreadRegistered( return nextState; } +/** + * Write thread state from the **shell stream** (all-threads subscription). + * + * Owns: sidebarThreadSummaryById (pre-computed server-side: hasPendingApprovals + * / hasPendingUserInput / hasActionableProposedPlan / latestUserMessageAt). This + * is the single source of truth for sidebar data. The detail stream MUST NOT + * write here — re-deriving the summary fields from detail state would cause + * ghost "Pending Approval" badges on resolved threads (MEMORY.md, upstream #1996). + * + * Also writes threadShellById / threadSessionById / threadTurnStateById as the + * authoritative source for those fields (the detail stream may also write them + * as fallback while shell events are in flight; structural equality checks in + * both writers prevent unnecessary re-renders). + * + * Does NOT write message/activity/proposedPlan/turnDiff content — that is + * detail-stream-only. + */ function writeThreadShellState( state: EnvironmentState, nextThread: { @@ -1041,6 +1020,19 @@ function buildProjectState( }; } +/** + * Build detail-stream state slice for a batch of threads (legacy read-model path). + * + * Owns: messages, activities, proposedPlans, turn diff summaries, plus + * threadShellById / threadSessionById / threadTurnStateById as fallback state + * (the shell stream is the authoritative writer for those three). + * + * Does NOT populate sidebarThreadSummaryById — the `Thread` type carries detail + * data (messages/activities/proposedPlans), not the server-computed summary + * fields (hasPendingApprovals / hasPendingUserInput / hasActionableProposedPlan + * / latestUserMessageAt) that live on OrchestrationThreadShell. The shell + * stream is the single source of truth for sidebar data. + */ function buildThreadState( threads: ReadonlyArray, ): Pick< @@ -1058,7 +1050,6 @@ function buildThreadState( | "proposedPlanByThreadId" | "turnDiffIdsByThreadId" | "turnDiffSummaryByThreadId" - | "sidebarThreadSummaryById" > { const threadIds: ThreadId[] = []; const threadIdsByProjectId: Record = {}; @@ -1073,7 +1064,6 @@ function buildThreadState( const proposedPlanByThreadId: Record> = {}; const turnDiffIdsByThreadId: Record = {}; const turnDiffSummaryByThreadId: Record> = {}; - const sidebarThreadSummaryById: Record = {}; for (const thread of threads) { threadIds.push(thread.id); @@ -1096,7 +1086,6 @@ function buildThreadState( const turnDiffSlice = buildTurnDiffSlice(thread); turnDiffIdsByThreadId[thread.id] = turnDiffSlice.ids; turnDiffSummaryByThreadId[thread.id] = turnDiffSlice.byId; - sidebarThreadSummaryById[thread.id] = buildSidebarThreadSummary(thread); } return { @@ -1113,7 +1102,6 @@ function buildThreadState( proposedPlanByThreadId, turnDiffIdsByThreadId, turnDiffSummaryByThreadId, - sidebarThreadSummaryById, }; }