From 596ac1642d03ab397e0084ef93f21fa41ae92129 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 10 Apr 2026 17:21:51 -0700 Subject: [PATCH 1/3] Memoize derived thread reads - Extract shared thread derivation - Add existence selector for route checks - Cover memoization behavior with tests --- .../routes/_chat.$environmentId.$threadId.tsx | 11 +- apps/web/src/store.test.ts | 124 ++++++++++++++ apps/web/src/store.ts | 103 ++---------- apps/web/src/storeSelectors.ts | 130 ++------------ apps/web/src/threadDerivation.ts | 158 ++++++++++++++++++ 5 files changed, 309 insertions(+), 217 deletions(-) create mode 100644 apps/web/src/threadDerivation.ts diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 9e75ebe7f73..a5c85be8f47 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -17,7 +17,7 @@ import { stripDiffSearchParams, } from "../diffRouteSearch"; import { useMediaQuery } from "../hooks/useMediaQuery"; -import { selectEnvironmentState, selectThreadByRef, useStore } from "../store"; +import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store"; import { createThreadSelectorByRef } from "../storeSelectors"; import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes"; import { Sheet, SheetPopup } from "../components/ui/sheet"; @@ -172,7 +172,7 @@ function ChatThreadRouteView() { (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).bootstrapComplete, ); const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); - const threadExists = useStore((store) => selectThreadByRef(store, threadRef) !== undefined); + const threadExists = useStore((store) => selectThreadExistsByRef(store, threadRef)); const environmentHasServerThreads = useStore( (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).threadIds.length > 0, ); @@ -208,6 +208,7 @@ function ChatThreadRouteView() { if (!threadRef) { return; } + setHasOpenedDiff(true); void navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef), @@ -218,12 +219,6 @@ function ChatThreadRouteView() { }); }, [navigate, threadRef]); - useEffect(() => { - if (diffOpen) { - setHasOpenedDiff(true); - } - }, [diffOpen]); - useEffect(() => { if (!threadRef || !bootstrapComplete) { return; diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 7b7e786b67d..951830dbbc2 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -18,6 +18,8 @@ import { applyOrchestrationEvents, selectEnvironmentState, selectProjectsAcrossEnvironments, + selectThreadByRef, + selectThreadExistsByRef, setThreadBranch, selectThreadsAcrossEnvironments, syncServerReadModel, @@ -245,6 +247,128 @@ function makeEvent( } as Extract; } +describe("thread selection memoization", () => { + it("returns stable thread references for repeated reads of the same state", () => { + const thread = makeThread({ + messages: [ + { + id: MessageId.make("message-1"), + role: "user", + text: "hello", + createdAt: "2026-02-13T00:01:00.000Z", + streaming: false, + }, + ], + activities: [ + { + id: EventId.make("activity-1"), + tone: "info", + kind: "step", + summary: "working", + payload: {}, + turnId: TurnId.make("turn-1"), + createdAt: "2026-02-13T00:01:30.000Z", + }, + ], + proposedPlans: [ + { + id: "plan-1", + turnId: null, + planMarkdown: "plan", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-02-13T00:02:00.000Z", + updatedAt: "2026-02-13T00:02:00.000Z", + }, + ], + turnDiffSummaries: [ + { + turnId: TurnId.make("turn-1"), + completedAt: "2026-02-13T00:03:00.000Z", + files: [], + }, + ], + }); + const state = makeState(thread); + const ref = scopeThreadRef(thread.environmentId, thread.id); + + const first = selectThreadByRef(state, ref); + const second = selectThreadByRef(state, ref); + + expect(first).toBeDefined(); + expect(second).toBe(first); + expect(second?.messages).toBe(first?.messages); + expect(second?.activities).toBe(first?.activities); + expect(second?.proposedPlans).toBe(first?.proposedPlans); + expect(second?.turnDiffSummaries).toBe(first?.turnDiffSummaries); + }); + + it("reuses the derived thread when the app state wrapper changes but thread data does not", () => { + const thread = makeThread({ + messages: [ + { + id: MessageId.make("message-1"), + role: "assistant", + text: "done", + createdAt: "2026-02-13T00:01:00.000Z", + streaming: false, + }, + ], + }); + const state = makeState(thread); + const ref = scopeThreadRef(thread.environmentId, thread.id); + const wrappedState: AppState = { + ...state, + environmentStateById: { ...state.environmentStateById }, + }; + + const first = selectThreadByRef(state, ref); + const second = selectThreadByRef(wrappedState, ref); + + expect(second).toBe(first); + }); + + it("updates the derived thread when the underlying thread data changes", () => { + const thread = makeThread(); + const ref = scopeThreadRef(thread.environmentId, thread.id); + const firstState = makeState(thread); + const secondState = makeState({ + ...thread, + messages: [ + { + id: MessageId.make("message-2"), + role: "user", + text: "new", + createdAt: "2026-02-13T00:04:00.000Z", + streaming: false, + }, + ], + }); + + const first = selectThreadByRef(firstState, ref); + const second = selectThreadByRef(secondState, ref); + + expect(second).not.toBe(first); + expect(second?.messages).toHaveLength(1); + expect(second?.messages[0]?.text).toBe("new"); + }); + + it("checks thread existence without materializing the full thread", () => { + const thread = makeThread(); + const state = makeState(thread); + const ref = scopeThreadRef(thread.environmentId, thread.id); + + expect(selectThreadExistsByRef(state, ref)).toBe(true); + expect( + selectThreadExistsByRef( + state, + scopeThreadRef(thread.environmentId, ThreadId.make("missing")), + ), + ).toBe(false); + expect(selectThreadExistsByRef(state, null)).toBe(false); + }); +}); + function makeReadModelThread(overrides: Partial) { return { id: ThreadId.make("thread-1"), diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 10f4eac3872..58a0697c7d0 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -38,6 +38,7 @@ import { } from "./types"; import { resolveEnvironmentHttpUrl } from "./environments/runtime"; import { sanitizeThreadErrorMessage } from "./rpc/transportError"; +import { getThreadFromEnvironmentState } from "./threadDerivation"; export interface EnvironmentState { projectIds: ProjectId[]; @@ -94,19 +95,6 @@ const MAX_THREAD_CHECKPOINTS = 500; const MAX_THREAD_PROPOSED_PLANS = 200; const MAX_THREAD_ACTIVITIES = 500; const EMPTY_THREAD_IDS: ThreadId[] = []; -const EMPTY_MESSAGE_IDS: MessageId[] = []; -const EMPTY_ACTIVITY_IDS: string[] = []; -const EMPTY_PROPOSED_PLAN_IDS: string[] = []; -const EMPTY_TURN_IDS: TurnId[] = []; -const EMPTY_MESSAGES: ChatMessage[] = []; -const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; -const EMPTY_PROPOSED_PLANS: ProposedPlan[] = []; -const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = []; -const EMPTY_MESSAGE_MAP: Record = {}; -const EMPTY_ACTIVITY_MAP: Record = {}; -const EMPTY_PROPOSED_PLAN_MAP: Record = {}; -const EMPTY_TURN_DIFF_MAP: Record = {}; -const EMPTY_THREAD_TURN_STATE: ThreadTurnState = Object.freeze({ latestTurn: null }); function arraysEqual(left: readonly T[], right: readonly T[]): boolean { return left.length === right.length && left.every((value, index) => value === right[index]); @@ -403,78 +391,6 @@ function buildTurnDiffSlice(thread: Thread): { }; } -function selectThreadMessages(state: EnvironmentState, threadId: ThreadId): ChatMessage[] { - const ids = state.messageIdsByThreadId[threadId] ?? EMPTY_MESSAGE_IDS; - const byId = state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP; - if (ids.length === 0) { - return EMPTY_MESSAGES; - } - return ids.flatMap((id) => { - const message = byId[id]; - return message ? [message] : []; - }); -} - -function selectThreadActivities( - state: EnvironmentState, - threadId: ThreadId, -): OrchestrationThreadActivity[] { - const ids = state.activityIdsByThreadId[threadId] ?? EMPTY_ACTIVITY_IDS; - const byId = state.activityByThreadId[threadId] ?? EMPTY_ACTIVITY_MAP; - if (ids.length === 0) { - return EMPTY_ACTIVITIES; - } - return ids.flatMap((id) => { - const activity = byId[id]; - return activity ? [activity] : []; - }); -} - -function selectThreadProposedPlans(state: EnvironmentState, threadId: ThreadId): ProposedPlan[] { - const ids = state.proposedPlanIdsByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_IDS; - const byId = state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP; - if (ids.length === 0) { - return EMPTY_PROPOSED_PLANS; - } - return ids.flatMap((id) => { - const plan = byId[id]; - return plan ? [plan] : []; - }); -} - -function selectThreadTurnDiffSummaries( - state: EnvironmentState, - threadId: ThreadId, -): TurnDiffSummary[] { - const ids = state.turnDiffIdsByThreadId[threadId] ?? EMPTY_TURN_IDS; - const byId = state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP; - if (ids.length === 0) { - return EMPTY_TURN_DIFF_SUMMARIES; - } - return ids.flatMap((id) => { - const summary = byId[id]; - return summary ? [summary] : []; - }); -} - -function getThread(state: EnvironmentState, threadId: ThreadId): Thread | undefined { - const shell = state.threadShellById[threadId]; - if (!shell) { - return undefined; - } - const turnState = state.threadTurnStateById[threadId] ?? EMPTY_THREAD_TURN_STATE; - return { - ...shell, - session: state.threadSessionById[threadId] ?? null, - latestTurn: turnState.latestTurn, - pendingSourceProposedPlan: turnState.pendingSourceProposedPlan, - messages: selectThreadMessages(state, threadId), - activities: selectThreadActivities(state, threadId), - proposedPlans: selectThreadProposedPlans(state, threadId), - turnDiffSummaries: selectThreadTurnDiffSummaries(state, threadId), - }; -} - function getProjects(state: EnvironmentState): Project[] { return state.projectIds.flatMap((projectId) => { const project = state.projectById[projectId]; @@ -484,7 +400,7 @@ function getProjects(state: EnvironmentState): Project[] { function getThreads(state: EnvironmentState): Thread[] { return state.threadIds.flatMap((threadId) => { - const thread = getThread(state, threadId); + const thread = getThreadFromEnvironmentState(state, threadId); return thread ? [thread] : []; }); } @@ -896,7 +812,7 @@ function updateThreadState( threadId: ThreadId, updater: (thread: Thread) => Thread, ): EnvironmentState { - const currentThread = getThread(state, threadId); + const currentThread = getThreadFromEnvironmentState(state, threadId); if (!currentThread) { return state; } @@ -1163,7 +1079,7 @@ function applyEnvironmentOrchestrationEvent( } case "thread.created": { - const previousThread = getThread(state, event.payload.threadId); + const previousThread = getThreadFromEnvironmentState(state, event.payload.threadId); const nextThread = mapThread( { id: event.payload.threadId, @@ -1669,10 +1585,19 @@ export function selectThreadByRef( ref: ScopedThreadRef | null | undefined, ): Thread | undefined { return ref - ? getThread(selectEnvironmentState(state, ref.environmentId), ref.threadId) + ? getThreadFromEnvironmentState(selectEnvironmentState(state, ref.environmentId), ref.threadId) : undefined; } +export function selectThreadExistsByRef( + state: AppState, + ref: ScopedThreadRef | null | undefined, +): boolean { + return ref + ? selectEnvironmentState(state, ref.environmentId).threadShellById[ref.threadId] !== undefined + : false; +} + export function selectSidebarThreadSummaryByRef( state: AppState, ref: ScopedThreadRef | null | undefined, diff --git a/apps/web/src/storeSelectors.ts b/apps/web/src/storeSelectors.ts index 84802ae6d56..02b88f31b18 100644 --- a/apps/web/src/storeSelectors.ts +++ b/apps/web/src/storeSelectors.ts @@ -1,40 +1,7 @@ -import { - type MessageId, - type ScopedProjectRef, - type ScopedThreadRef, - type ThreadId, - type TurnId, -} from "@t3tools/contracts"; +import { type ScopedProjectRef, type ScopedThreadRef, type ThreadId } from "@t3tools/contracts"; import { selectEnvironmentState, type AppState, type EnvironmentState } from "./store"; -import { - type ChatMessage, - type Project, - type ProposedPlan, - type SidebarThreadSummary, - type Thread, - type ThreadSession, - type ThreadTurnState, - type TurnDiffSummary, -} from "./types"; - -const EMPTY_MESSAGES: ChatMessage[] = []; -const EMPTY_ACTIVITIES: Thread["activities"] = []; -const EMPTY_PROPOSED_PLANS: ProposedPlan[] = []; -const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = []; - -function collectByIds( - ids: readonly TKey[] | undefined, - byId: Record | undefined, -): TValue[] { - if (!ids || ids.length === 0 || !byId) { - return []; - } - - return ids.flatMap((id) => { - const value = byId[id]; - return value ? [value] : []; - }); -} +import { type Project, type SidebarThreadSummary, type Thread } from "./types"; +import { getThreadFromEnvironmentState } from "./threadDerivation"; export function createProjectSelectorByRef( ref: ScopedProjectRef | null | undefined, @@ -55,17 +22,8 @@ export function createSidebarThreadSummarySelectorByRef( function createScopedThreadSelector( resolveRef: (state: AppState) => ScopedThreadRef | null | undefined, ): (state: AppState) => Thread | undefined { - let previousShell: EnvironmentState["threadShellById"][ThreadId] | undefined; - let previousSession: ThreadSession | null | undefined; - let previousTurnState: ThreadTurnState | undefined; - let previousMessageIds: MessageId[] | undefined; - let previousMessagesById: EnvironmentState["messageByThreadId"][ThreadId] | undefined; - let previousActivityIds: string[] | undefined; - let previousActivitiesById: EnvironmentState["activityByThreadId"][ThreadId] | undefined; - let previousProposedPlanIds: string[] | undefined; - let previousProposedPlansById: EnvironmentState["proposedPlanByThreadId"][ThreadId] | undefined; - let previousTurnDiffIds: TurnId[] | undefined; - let previousTurnDiffsById: EnvironmentState["turnDiffSummaryByThreadId"][ThreadId] | undefined; + let previousEnvironmentState: EnvironmentState | undefined; + let previousThreadId: ThreadId | undefined; let previousThread: Thread | undefined; return (state) => { @@ -75,85 +33,17 @@ function createScopedThreadSelector( } const environmentState = selectEnvironmentState(state, ref.environmentId); - const threadId = ref.threadId; - const shell = environmentState.threadShellById[threadId]; - if (!shell) { - return undefined; - } - - const session = environmentState.threadSessionById[threadId] ?? null; - const turnState = environmentState.threadTurnStateById[threadId]; - const messageIds = environmentState.messageIdsByThreadId[threadId]; - const messageById = environmentState.messageByThreadId[threadId]; - const activityIds = environmentState.activityIdsByThreadId[threadId]; - const activityById = environmentState.activityByThreadId[threadId]; - const proposedPlanIds = environmentState.proposedPlanIdsByThreadId[threadId]; - const proposedPlanById = environmentState.proposedPlanByThreadId[threadId]; - const turnDiffIds = environmentState.turnDiffIdsByThreadId[threadId]; - const turnDiffById = environmentState.turnDiffSummaryByThreadId[threadId]; - if ( previousThread && - previousShell === shell && - previousSession === session && - previousTurnState === turnState && - previousMessageIds === messageIds && - previousMessagesById === messageById && - previousActivityIds === activityIds && - previousActivitiesById === activityById && - previousProposedPlanIds === proposedPlanIds && - previousProposedPlansById === proposedPlanById && - previousTurnDiffIds === turnDiffIds && - previousTurnDiffsById === turnDiffById + previousEnvironmentState === environmentState && + previousThreadId === ref.threadId ) { return previousThread; } - const nextThread: Thread = { - ...shell, - session, - latestTurn: turnState?.latestTurn ?? null, - pendingSourceProposedPlan: turnState?.pendingSourceProposedPlan, - messages: collectByIds(messageIds, messageById) as Thread["messages"] extends ChatMessage[] - ? ChatMessage[] - : never, - activities: collectByIds(activityIds, activityById) as Thread["activities"] extends Array< - infer _ - > - ? Thread["activities"] - : never, - proposedPlans: collectByIds( - proposedPlanIds, - proposedPlanById, - ) as Thread["proposedPlans"] extends ProposedPlan[] ? ProposedPlan[] : never, - turnDiffSummaries: collectByIds( - turnDiffIds, - turnDiffById, - ) as Thread["turnDiffSummaries"] extends TurnDiffSummary[] ? TurnDiffSummary[] : never, - }; - - previousShell = shell; - previousSession = session; - previousTurnState = turnState; - previousMessageIds = messageIds; - previousMessagesById = messageById; - previousActivityIds = activityIds; - previousActivitiesById = activityById; - previousProposedPlanIds = proposedPlanIds; - previousProposedPlansById = proposedPlanById; - previousTurnDiffIds = turnDiffIds; - previousTurnDiffsById = turnDiffById; - previousThread = { - ...nextThread, - messages: nextThread.messages.length === 0 ? EMPTY_MESSAGES : nextThread.messages, - activities: nextThread.activities.length === 0 ? EMPTY_ACTIVITIES : nextThread.activities, - proposedPlans: - nextThread.proposedPlans.length === 0 ? EMPTY_PROPOSED_PLANS : nextThread.proposedPlans, - turnDiffSummaries: - nextThread.turnDiffSummaries.length === 0 - ? EMPTY_TURN_DIFF_SUMMARIES - : nextThread.turnDiffSummaries, - }; + previousEnvironmentState = environmentState; + previousThreadId = ref.threadId; + previousThread = getThreadFromEnvironmentState(environmentState, ref.threadId); return previousThread; }; } diff --git a/apps/web/src/threadDerivation.ts b/apps/web/src/threadDerivation.ts new file mode 100644 index 00000000000..0539c109efe --- /dev/null +++ b/apps/web/src/threadDerivation.ts @@ -0,0 +1,158 @@ +import type { MessageId, ThreadId, TurnId } from "@t3tools/contracts"; +import type { EnvironmentState } from "./store"; +import type { + ChatMessage, + ProposedPlan, + Thread, + ThreadSession, + ThreadShell, + ThreadTurnState, + TurnDiffSummary, +} from "./types"; + +const EMPTY_MESSAGES: ChatMessage[] = []; +const EMPTY_ACTIVITIES: Thread["activities"] = []; +const EMPTY_PROPOSED_PLANS: ProposedPlan[] = []; +const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = []; +const EMPTY_MESSAGE_MAP: Record = {}; +const EMPTY_ACTIVITY_MAP: Record = {}; +const EMPTY_PROPOSED_PLAN_MAP: Record = {}; +const EMPTY_TURN_DIFF_MAP: Record = {}; + +const collectedByIdsCache = new WeakMap>(); +const threadCache = new WeakMap< + ThreadShell, + { + session: ThreadSession | null; + turnState: ThreadTurnState | undefined; + messages: Thread["messages"]; + activities: Thread["activities"]; + proposedPlans: Thread["proposedPlans"]; + turnDiffSummaries: Thread["turnDiffSummaries"]; + thread: Thread; + } +>(); + +function collectByIds( + ids: readonly TKey[] | undefined, + byId: Record | undefined, + emptyValue: TValue[], +): TValue[] { + if (!ids || ids.length === 0 || !byId) { + return emptyValue; + } + + const cachedByRecord = collectedByIdsCache.get(ids); + const cached = cachedByRecord?.get(byId); + if (cached) { + return cached as TValue[]; + } + + const nextValues = ids.flatMap((id) => { + const value = byId[id]; + return value ? [value] : []; + }); + const nextCachedByRecord = cachedByRecord ?? new WeakMap(); + nextCachedByRecord.set(byId, nextValues); + if (!cachedByRecord) { + collectedByIdsCache.set(ids, nextCachedByRecord); + } + return nextValues; +} + +export function selectThreadMessages( + state: EnvironmentState, + threadId: ThreadId, +): Thread["messages"] { + return collectByIds( + state.messageIdsByThreadId[threadId], + state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP, + EMPTY_MESSAGES, + ); +} + +export function selectThreadActivities( + state: EnvironmentState, + threadId: ThreadId, +): Thread["activities"] { + return collectByIds( + state.activityIdsByThreadId[threadId], + state.activityByThreadId[threadId] ?? EMPTY_ACTIVITY_MAP, + EMPTY_ACTIVITIES, + ); +} + +export function selectThreadProposedPlans( + state: EnvironmentState, + threadId: ThreadId, +): Thread["proposedPlans"] { + return collectByIds( + state.proposedPlanIdsByThreadId[threadId], + state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP, + EMPTY_PROPOSED_PLANS, + ); +} + +export function selectThreadTurnDiffSummaries( + state: EnvironmentState, + threadId: ThreadId, +): Thread["turnDiffSummaries"] { + return collectByIds( + state.turnDiffIdsByThreadId[threadId], + state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP, + EMPTY_TURN_DIFF_SUMMARIES, + ); +} + +export function getThreadFromEnvironmentState( + state: EnvironmentState, + threadId: ThreadId, +): Thread | undefined { + const shell = state.threadShellById[threadId]; + if (!shell) { + return undefined; + } + + const session = state.threadSessionById[threadId] ?? null; + const turnState = state.threadTurnStateById[threadId]; + const messages = selectThreadMessages(state, threadId); + const activities = selectThreadActivities(state, threadId); + const proposedPlans = selectThreadProposedPlans(state, threadId); + const turnDiffSummaries = selectThreadTurnDiffSummaries(state, threadId); + const cached = threadCache.get(shell); + + if ( + cached && + cached.session === session && + cached.turnState === turnState && + cached.messages === messages && + cached.activities === activities && + cached.proposedPlans === proposedPlans && + cached.turnDiffSummaries === turnDiffSummaries + ) { + return cached.thread; + } + + const thread: Thread = { + ...shell, + session, + latestTurn: turnState?.latestTurn ?? null, + pendingSourceProposedPlan: turnState?.pendingSourceProposedPlan, + messages, + activities, + proposedPlans, + turnDiffSummaries, + }; + + threadCache.set(shell, { + session, + turnState, + messages, + activities, + proposedPlans, + turnDiffSummaries, + thread, + }); + + return thread; +} From 594dd8f8f2b70fd2b2ad2d22accb192e8c29ab67 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 10 Apr 2026 18:49:30 -0700 Subject: [PATCH 2/3] Memoize diff panel state by thread - Track diff-open state per thread key - Notify route when chat opens the diff panel --- apps/web/src/components/ChatView.tsx | 12 ++++++--- .../routes/_chat.$environmentId.$threadId.tsx | 27 ++++++++++++++++--- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 44ad594ff9b..221fcfd7cbe 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -310,12 +310,14 @@ type ChatViewProps = | { environmentId: EnvironmentId; threadId: ThreadId; + onDiffPanelOpen?: () => void; routeKind: "server"; draftId?: never; } | { environmentId: EnvironmentId; threadId: ThreadId; + onDiffPanelOpen?: () => void; routeKind: "draft"; draftId: DraftId; }; @@ -569,7 +571,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra }); export default function ChatView(props: ChatViewProps) { - const { environmentId, threadId, routeKind } = props; + const { environmentId, threadId, routeKind, onDiffPanelOpen } = props; const draftId = routeKind === "draft" ? props.draftId : null; const routeThreadRef = useMemo( () => scopeThreadRef(environmentId, threadId), @@ -1470,6 +1472,9 @@ export default function ChatView(props: ChatViewProps) { if (!isServerThread) { return; } + if (!diffOpen) { + onDiffPanelOpen?.(); + } void navigate({ to: "/$environmentId/$threadId", params: { @@ -1482,7 +1487,7 @@ export default function ChatView(props: ChatViewProps) { return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; }, }); - }, [diffOpen, environmentId, isServerThread, navigate, threadId]); + }, [diffOpen, environmentId, isServerThread, navigate, onDiffPanelOpen, threadId]); const envLocked = Boolean( activeThread && @@ -3245,6 +3250,7 @@ export default function ChatView(props: ChatViewProps) { if (!isServerThread) { return; } + onDiffPanelOpen?.(); void navigate({ to: "/$environmentId/$threadId", params: { @@ -3259,7 +3265,7 @@ export default function ChatView(props: ChatViewProps) { }, }); }, - [environmentId, isServerThread, navigate, threadId], + [environmentId, isServerThread, navigate, onDiffPanelOpen, threadId], ); const onRevertUserMessage = useCallback( (messageId: MessageId) => { diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index a5c85be8f47..b62fd6b9c65 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -193,7 +193,26 @@ function ChatThreadRouteView() { const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; const diffOpen = search.diff === "1"; const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); - const [hasOpenedDiff, setHasOpenedDiff] = useState(diffOpen); + const currentThreadKey = threadRef ? `${threadRef.environmentId}:${threadRef.threadId}` : null; + const [diffPanelMountState, setDiffPanelMountState] = useState(() => ({ + threadKey: currentThreadKey, + hasOpenedDiff: diffOpen, + })); + const hasOpenedDiff = + diffPanelMountState.threadKey === currentThreadKey + ? diffPanelMountState.hasOpenedDiff + : diffOpen; + const markDiffOpened = useCallback(() => { + setDiffPanelMountState((previous) => { + if (previous.threadKey === currentThreadKey && previous.hasOpenedDiff) { + return previous; + } + return { + threadKey: currentThreadKey, + hasOpenedDiff: true, + }; + }); + }, [currentThreadKey]); const closeDiff = useCallback(() => { if (!threadRef) { return; @@ -208,7 +227,7 @@ function ChatThreadRouteView() { if (!threadRef) { return; } - setHasOpenedDiff(true); + markDiffOpened(); void navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef), @@ -217,7 +236,7 @@ function ChatThreadRouteView() { return { ...rest, diff: "1" }; }, }); - }, [navigate, threadRef]); + }, [markDiffOpened, navigate, threadRef]); useEffect(() => { if (!threadRef || !bootstrapComplete) { @@ -249,6 +268,7 @@ function ChatThreadRouteView() { @@ -268,6 +288,7 @@ function ChatThreadRouteView() { From 081d29b71572141fb7f1c251af5869bc8c1ef018 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 11 Apr 2026 02:06:03 +0000 Subject: [PATCH 3/3] Remove unnecessary exports from internal helper functions in threadDerivation.ts Applied via @cursor push command --- apps/web/src/threadDerivation.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/apps/web/src/threadDerivation.ts b/apps/web/src/threadDerivation.ts index 0539c109efe..0766f0c8e13 100644 --- a/apps/web/src/threadDerivation.ts +++ b/apps/web/src/threadDerivation.ts @@ -60,10 +60,7 @@ function collectByIds( return nextValues; } -export function selectThreadMessages( - state: EnvironmentState, - threadId: ThreadId, -): Thread["messages"] { +function selectThreadMessages(state: EnvironmentState, threadId: ThreadId): Thread["messages"] { return collectByIds( state.messageIdsByThreadId[threadId], state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP, @@ -71,10 +68,7 @@ export function selectThreadMessages( ); } -export function selectThreadActivities( - state: EnvironmentState, - threadId: ThreadId, -): Thread["activities"] { +function selectThreadActivities(state: EnvironmentState, threadId: ThreadId): Thread["activities"] { return collectByIds( state.activityIdsByThreadId[threadId], state.activityByThreadId[threadId] ?? EMPTY_ACTIVITY_MAP, @@ -82,7 +76,7 @@ export function selectThreadActivities( ); } -export function selectThreadProposedPlans( +function selectThreadProposedPlans( state: EnvironmentState, threadId: ThreadId, ): Thread["proposedPlans"] { @@ -93,7 +87,7 @@ export function selectThreadProposedPlans( ); } -export function selectThreadTurnDiffSummaries( +function selectThreadTurnDiffSummaries( state: EnvironmentState, threadId: ThreadId, ): Thread["turnDiffSummaries"] {