| author | |
| committer | |
| log | b80e8476b86b6ce2b39c7c5564fed92fd80b4890 |
| tree | 334deb24da319eeb01fa53656e967d37cccba9bc |
| parent | e0e01b4a545d9a8de9d1ec3e5abe98fa8dd4575e |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Cursor Agent <cursoragent@cursor.com>6 files changed, 335 insertions(+), 222 deletions(-)
apps/web/src/components/ChatView.tsx+9-3| ... | @@ -310,12 +310,14 @@ type ChatViewProps = | ... | @@ -310,12 +310,14 @@ type ChatViewProps = |
| 310 | | { | 310 | | { |
| 311 | environmentId: EnvironmentId; | 311 | environmentId: EnvironmentId; |
| 312 | threadId: ThreadId; | 312 | threadId: ThreadId; |
| 313 | onDiffPanelOpen?: () => void; | ||
| 313 | routeKind: "server"; | 314 | routeKind: "server"; |
| 314 | draftId?: never; | 315 | draftId?: never; |
| 315 | } | 316 | } |
| 316 | | { | 317 | | { |
| 317 | environmentId: EnvironmentId; | 318 | environmentId: EnvironmentId; |
| 318 | threadId: ThreadId; | 319 | threadId: ThreadId; |
| 320 | onDiffPanelOpen?: () => void; | ||
| 319 | routeKind: "draft"; | 321 | routeKind: "draft"; |
| 320 | draftId: DraftId; | 322 | draftId: DraftId; |
| 321 | }; | 323 | }; |
| ... | @@ -569,7 +571,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -569,7 +571,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 569 | }); | 571 | }); |
| 570 | 572 | ||
| 571 | export default function ChatView(props: ChatViewProps) { | 573 | export default function ChatView(props: ChatViewProps) { |
| 572 | const { environmentId, threadId, routeKind } = props; | 574 | const { environmentId, threadId, routeKind, onDiffPanelOpen } = props; |
| 573 | const draftId = routeKind === "draft" ? props.draftId : null; | 575 | const draftId = routeKind === "draft" ? props.draftId : null; |
| 574 | const routeThreadRef = useMemo( | 576 | const routeThreadRef = useMemo( |
| 575 | () => scopeThreadRef(environmentId, threadId), | 577 | () => scopeThreadRef(environmentId, threadId), |
| ... | @@ -1470,6 +1472,9 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1470,6 +1472,9 @@ export default function ChatView(props: ChatViewProps) { |
| 1470 | if (!isServerThread) { | 1472 | if (!isServerThread) { |
| 1471 | return; | 1473 | return; |
| 1472 | } | 1474 | } |
| 1475 | if (!diffOpen) { | ||
| 1476 | onDiffPanelOpen?.(); | ||
| 1477 | } | ||
| 1473 | void navigate({ | 1478 | void navigate({ |
| 1474 | to: "/$environmentId/$threadId", | 1479 | to: "/$environmentId/$threadId", |
| 1475 | params: { | 1480 | params: { |
| ... | @@ -1482,7 +1487,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1482,7 +1487,7 @@ export default function ChatView(props: ChatViewProps) { |
| 1482 | return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; | 1487 | return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; |
| 1483 | }, | 1488 | }, |
| 1484 | }); | 1489 | }); |
| 1485 | }, [diffOpen, environmentId, isServerThread, navigate, threadId]); | 1490 | }, [diffOpen, environmentId, isServerThread, navigate, onDiffPanelOpen, threadId]); |
| 1486 | 1491 | ||
| 1487 | const envLocked = Boolean( | 1492 | const envLocked = Boolean( |
| 1488 | activeThread && | 1493 | activeThread && |
| ... | @@ -3245,6 +3250,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3245,6 +3250,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3245 | if (!isServerThread) { | 3250 | if (!isServerThread) { |
| 3246 | return; | 3251 | return; |
| 3247 | } | 3252 | } |
| 3253 | onDiffPanelOpen?.(); | ||
| 3248 | void navigate({ | 3254 | void navigate({ |
| 3249 | to: "/$environmentId/$threadId", | 3255 | to: "/$environmentId/$threadId", |
| 3250 | params: { | 3256 | params: { |
| ... | @@ -3259,7 +3265,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3259,7 +3265,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3259 | }, | 3265 | }, |
| 3260 | }); | 3266 | }); |
| 3261 | }, | 3267 | }, |
| 3262 | [environmentId, isServerThread, navigate, threadId], | 3268 | [environmentId, isServerThread, navigate, onDiffPanelOpen, threadId], |
| 3263 | ); | 3269 | ); |
| 3264 | const onRevertUserMessage = useCallback( | 3270 | const onRevertUserMessage = useCallback( |
| 3265 | (messageId: MessageId) => { | 3271 | (messageId: MessageId) => { |
apps/web/src/routes/_chat.$environmentId.$threadId.tsx+26-10| ... | @@ -17,7 +17,7 @@ import { | ... | @@ -17,7 +17,7 @@ import { |
| 17 | stripDiffSearchParams, | 17 | stripDiffSearchParams, |
| 18 | } from "../diffRouteSearch"; | 18 | } from "../diffRouteSearch"; |
| 19 | import { useMediaQuery } from "../hooks/useMediaQuery"; | 19 | import { useMediaQuery } from "../hooks/useMediaQuery"; |
| 20 | import { selectEnvironmentState, selectThreadByRef, useStore } from "../store"; | 20 | import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store"; |
| 21 | import { createThreadSelectorByRef } from "../storeSelectors"; | 21 | import { createThreadSelectorByRef } from "../storeSelectors"; |
| 22 | import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes"; | 22 | import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes"; |
| 23 | import { Sheet, SheetPopup } from "../components/ui/sheet"; | 23 | import { Sheet, SheetPopup } from "../components/ui/sheet"; |
| ... | @@ -172,7 +172,7 @@ function ChatThreadRouteView() { | ... | @@ -172,7 +172,7 @@ function ChatThreadRouteView() { |
| 172 | (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).bootstrapComplete, | 172 | (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).bootstrapComplete, |
| 173 | ); | 173 | ); |
| 174 | const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); | 174 | const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); |
| 175 | const threadExists = useStore((store) => selectThreadByRef(store, threadRef) !== undefined); | 175 | const threadExists = useStore((store) => selectThreadExistsByRef(store, threadRef)); |
| 176 | const environmentHasServerThreads = useStore( | 176 | const environmentHasServerThreads = useStore( |
| 177 | (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).threadIds.length > 0, | 177 | (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).threadIds.length > 0, |
| 178 | ); | 178 | ); |
| ... | @@ -193,7 +193,26 @@ function ChatThreadRouteView() { | ... | @@ -193,7 +193,26 @@ function ChatThreadRouteView() { |
| 193 | const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; | 193 | const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; |
| 194 | const diffOpen = search.diff === "1"; | 194 | const diffOpen = search.diff === "1"; |
| 195 | const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); | 195 | const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); |
| 196 | const [hasOpenedDiff, setHasOpenedDiff] = useState(diffOpen); | 196 | const currentThreadKey = threadRef ? `${threadRef.environmentId}:${threadRef.threadId}` : null; |
| 197 | const [diffPanelMountState, setDiffPanelMountState] = useState(() => ({ | ||
| 198 | threadKey: currentThreadKey, | ||
| 199 | hasOpenedDiff: diffOpen, | ||
| 200 | })); | ||
| 201 | const hasOpenedDiff = | ||
| 202 | diffPanelMountState.threadKey === currentThreadKey | ||
| 203 | ? diffPanelMountState.hasOpenedDiff | ||
| 204 | : diffOpen; | ||
| 205 | const markDiffOpened = useCallback(() => { | ||
| 206 | setDiffPanelMountState((previous) => { | ||
| 207 | if (previous.threadKey === currentThreadKey && previous.hasOpenedDiff) { | ||
| 208 | return previous; | ||
| 209 | } | ||
| 210 | return { | ||
| 211 | threadKey: currentThreadKey, | ||
| 212 | hasOpenedDiff: true, | ||
| 213 | }; | ||
| 214 | }); | ||
| 215 | }, [currentThreadKey]); | ||
| 197 | const closeDiff = useCallback(() => { | 216 | const closeDiff = useCallback(() => { |
| 198 | if (!threadRef) { | 217 | if (!threadRef) { |
| 199 | return; | 218 | return; |
| ... | @@ -208,6 +227,7 @@ function ChatThreadRouteView() { | ... | @@ -208,6 +227,7 @@ function ChatThreadRouteView() { |
| 208 | if (!threadRef) { | 227 | if (!threadRef) { |
| 209 | return; | 228 | return; |
| 210 | } | 229 | } |
| 230 | markDiffOpened(); | ||
| 211 | void navigate({ | 231 | void navigate({ |
| 212 | to: "/$environmentId/$threadId", | 232 | to: "/$environmentId/$threadId", |
| 213 | params: buildThreadRouteParams(threadRef), | 233 | params: buildThreadRouteParams(threadRef), |
| ... | @@ -216,13 +236,7 @@ function ChatThreadRouteView() { | ... | @@ -216,13 +236,7 @@ function ChatThreadRouteView() { |
| 216 | return { ...rest, diff: "1" }; | 236 | return { ...rest, diff: "1" }; |
| 217 | }, | 237 | }, |
| 218 | }); | 238 | }); |
| 219 | }, [navigate, threadRef]); | 239 | }, [markDiffOpened, navigate, threadRef]); |
| 220 | |||
| 221 | useEffect(() => { | ||
| 222 | if (diffOpen) { | ||
| 223 | setHasOpenedDiff(true); | ||
| 224 | } | ||
| 225 | }, [diffOpen]); | ||
| 226 | 240 | ||
| 227 | useEffect(() => { | 241 | useEffect(() => { |
| 228 | if (!threadRef || !bootstrapComplete) { | 242 | if (!threadRef || !bootstrapComplete) { |
| ... | @@ -254,6 +268,7 @@ function ChatThreadRouteView() { | ... | @@ -254,6 +268,7 @@ function ChatThreadRouteView() { |
| 254 | <ChatView | 268 | <ChatView |
| 255 | environmentId={threadRef.environmentId} | 269 | environmentId={threadRef.environmentId} |
| 256 | threadId={threadRef.threadId} | 270 | threadId={threadRef.threadId} |
| 271 | onDiffPanelOpen={markDiffOpened} | ||
| 257 | routeKind="server" | 272 | routeKind="server" |
| 258 | /> | 273 | /> |
| 259 | </SidebarInset> | 274 | </SidebarInset> |
| ... | @@ -273,6 +288,7 @@ function ChatThreadRouteView() { | ... | @@ -273,6 +288,7 @@ function ChatThreadRouteView() { |
| 273 | <ChatView | 288 | <ChatView |
| 274 | environmentId={threadRef.environmentId} | 289 | environmentId={threadRef.environmentId} |
| 275 | threadId={threadRef.threadId} | 290 | threadId={threadRef.threadId} |
| 291 | onDiffPanelOpen={markDiffOpened} | ||
| 276 | routeKind="server" | 292 | routeKind="server" |
| 277 | /> | 293 | /> |
| 278 | </SidebarInset> | 294 | </SidebarInset> |
apps/web/src/store.test.ts+124| ... | @@ -18,6 +18,8 @@ import { | ... | @@ -18,6 +18,8 @@ import { |
| 18 | applyOrchestrationEvents, | 18 | applyOrchestrationEvents, |
| 19 | selectEnvironmentState, | 19 | selectEnvironmentState, |
| 20 | selectProjectsAcrossEnvironments, | 20 | selectProjectsAcrossEnvironments, |
| 21 | selectThreadByRef, | ||
| 22 | selectThreadExistsByRef, | ||
| 21 | setThreadBranch, | 23 | setThreadBranch, |
| 22 | selectThreadsAcrossEnvironments, | 24 | selectThreadsAcrossEnvironments, |
| 23 | syncServerReadModel, | 25 | syncServerReadModel, |
| ... | @@ -245,6 +247,128 @@ function makeEvent<T extends OrchestrationEvent["type"]>( | ... | @@ -245,6 +247,128 @@ function makeEvent<T extends OrchestrationEvent["type"]>( |
| 245 | } as Extract<OrchestrationEvent, { type: T }>; | 247 | } as Extract<OrchestrationEvent, { type: T }>; |
| 246 | } | 248 | } |
| 247 | 249 | ||
| 250 | describe("thread selection memoization", () => { | ||
| 251 | it("returns stable thread references for repeated reads of the same state", () => { | ||
| 252 | const thread = makeThread({ | ||
| 253 | messages: [ | ||
| 254 | { | ||
| 255 | id: MessageId.make("message-1"), | ||
| 256 | role: "user", | ||
| 257 | text: "hello", | ||
| 258 | createdAt: "2026-02-13T00:01:00.000Z", | ||
| 259 | streaming: false, | ||
| 260 | }, | ||
| 261 | ], | ||
| 262 | activities: [ | ||
| 263 | { | ||
| 264 | id: EventId.make("activity-1"), | ||
| 265 | tone: "info", | ||
| 266 | kind: "step", | ||
| 267 | summary: "working", | ||
| 268 | payload: {}, | ||
| 269 | turnId: TurnId.make("turn-1"), | ||
| 270 | createdAt: "2026-02-13T00:01:30.000Z", | ||
| 271 | }, | ||
| 272 | ], | ||
| 273 | proposedPlans: [ | ||
| 274 | { | ||
| 275 | id: "plan-1", | ||
| 276 | turnId: null, | ||
| 277 | planMarkdown: "plan", | ||
| 278 | implementedAt: null, | ||
| 279 | implementationThreadId: null, | ||
| 280 | createdAt: "2026-02-13T00:02:00.000Z", | ||
| 281 | updatedAt: "2026-02-13T00:02:00.000Z", | ||
| 282 | }, | ||
| 283 | ], | ||
| 284 | turnDiffSummaries: [ | ||
| 285 | { | ||
| 286 | turnId: TurnId.make("turn-1"), | ||
| 287 | completedAt: "2026-02-13T00:03:00.000Z", | ||
| 288 | files: [], | ||
| 289 | }, | ||
| 290 | ], | ||
| 291 | }); | ||
| 292 | const state = makeState(thread); | ||
| 293 | const ref = scopeThreadRef(thread.environmentId, thread.id); | ||
| 294 | |||
| 295 | const first = selectThreadByRef(state, ref); | ||
| 296 | const second = selectThreadByRef(state, ref); | ||
| 297 | |||
| 298 | expect(first).toBeDefined(); | ||
| 299 | expect(second).toBe(first); | ||
| 300 | expect(second?.messages).toBe(first?.messages); | ||
| 301 | expect(second?.activities).toBe(first?.activities); | ||
| 302 | expect(second?.proposedPlans).toBe(first?.proposedPlans); | ||
| 303 | expect(second?.turnDiffSummaries).toBe(first?.turnDiffSummaries); | ||
| 304 | }); | ||
| 305 | |||
| 306 | it("reuses the derived thread when the app state wrapper changes but thread data does not", () => { | ||
| 307 | const thread = makeThread({ | ||
| 308 | messages: [ | ||
| 309 | { | ||
| 310 | id: MessageId.make("message-1"), | ||
| 311 | role: "assistant", | ||
| 312 | text: "done", | ||
| 313 | createdAt: "2026-02-13T00:01:00.000Z", | ||
| 314 | streaming: false, | ||
| 315 | }, | ||
| 316 | ], | ||
| 317 | }); | ||
| 318 | const state = makeState(thread); | ||
| 319 | const ref = scopeThreadRef(thread.environmentId, thread.id); | ||
| 320 | const wrappedState: AppState = { | ||
| 321 | ...state, | ||
| 322 | environmentStateById: { ...state.environmentStateById }, | ||
| 323 | }; | ||
| 324 | |||
| 325 | const first = selectThreadByRef(state, ref); | ||
| 326 | const second = selectThreadByRef(wrappedState, ref); | ||
| 327 | |||
| 328 | expect(second).toBe(first); | ||
| 329 | }); | ||
| 330 | |||
| 331 | it("updates the derived thread when the underlying thread data changes", () => { | ||
| 332 | const thread = makeThread(); | ||
| 333 | const ref = scopeThreadRef(thread.environmentId, thread.id); | ||
| 334 | const firstState = makeState(thread); | ||
| 335 | const secondState = makeState({ | ||
| 336 | ...thread, | ||
| 337 | messages: [ | ||
| 338 | { | ||
| 339 | id: MessageId.make("message-2"), | ||
| 340 | role: "user", | ||
| 341 | text: "new", | ||
| 342 | createdAt: "2026-02-13T00:04:00.000Z", | ||
| 343 | streaming: false, | ||
| 344 | }, | ||
| 345 | ], | ||
| 346 | }); | ||
| 347 | |||
| 348 | const first = selectThreadByRef(firstState, ref); | ||
| 349 | const second = selectThreadByRef(secondState, ref); | ||
| 350 | |||
| 351 | expect(second).not.toBe(first); | ||
| 352 | expect(second?.messages).toHaveLength(1); | ||
| 353 | expect(second?.messages[0]?.text).toBe("new"); | ||
| 354 | }); | ||
| 355 | |||
| 356 | it("checks thread existence without materializing the full thread", () => { | ||
| 357 | const thread = makeThread(); | ||
| 358 | const state = makeState(thread); | ||
| 359 | const ref = scopeThreadRef(thread.environmentId, thread.id); | ||
| 360 | |||
| 361 | expect(selectThreadExistsByRef(state, ref)).toBe(true); | ||
| 362 | expect( | ||
| 363 | selectThreadExistsByRef( | ||
| 364 | state, | ||
| 365 | scopeThreadRef(thread.environmentId, ThreadId.make("missing")), | ||
| 366 | ), | ||
| 367 | ).toBe(false); | ||
| 368 | expect(selectThreadExistsByRef(state, null)).toBe(false); | ||
| 369 | }); | ||
| 370 | }); | ||
| 371 | |||
| 248 | function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads"][number]>) { | 372 | function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads"][number]>) { |
| 249 | return { | 373 | return { |
| 250 | id: ThreadId.make("thread-1"), | 374 | id: ThreadId.make("thread-1"), |
apps/web/src/store.ts+14-89| ... | @@ -38,6 +38,7 @@ import { | ... | @@ -38,6 +38,7 @@ import { |
| 38 | } from "./types"; | 38 | } from "./types"; |
| 39 | import { resolveEnvironmentHttpUrl } from "./environments/runtime"; | 39 | import { resolveEnvironmentHttpUrl } from "./environments/runtime"; |
| 40 | import { sanitizeThreadErrorMessage } from "./rpc/transportError"; | 40 | import { sanitizeThreadErrorMessage } from "./rpc/transportError"; |
| 41 | import { getThreadFromEnvironmentState } from "./threadDerivation"; | ||
| 41 | 42 | ||
| 42 | export interface EnvironmentState { | 43 | export interface EnvironmentState { |
| 43 | projectIds: ProjectId[]; | 44 | projectIds: ProjectId[]; |
| ... | @@ -94,19 +95,6 @@ const MAX_THREAD_CHECKPOINTS = 500; | ... | @@ -94,19 +95,6 @@ const MAX_THREAD_CHECKPOINTS = 500; |
| 94 | const MAX_THREAD_PROPOSED_PLANS = 200; | 95 | const MAX_THREAD_PROPOSED_PLANS = 200; |
| 95 | const MAX_THREAD_ACTIVITIES = 500; | 96 | const MAX_THREAD_ACTIVITIES = 500; |
| 96 | const EMPTY_THREAD_IDS: ThreadId[] = []; | 97 | const EMPTY_THREAD_IDS: ThreadId[] = []; |
| 97 | const EMPTY_MESSAGE_IDS: MessageId[] = []; | ||
| 98 | const EMPTY_ACTIVITY_IDS: string[] = []; | ||
| 99 | const EMPTY_PROPOSED_PLAN_IDS: string[] = []; | ||
| 100 | const EMPTY_TURN_IDS: TurnId[] = []; | ||
| 101 | const EMPTY_MESSAGES: ChatMessage[] = []; | ||
| 102 | const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; | ||
| 103 | const EMPTY_PROPOSED_PLANS: ProposedPlan[] = []; | ||
| 104 | const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = []; | ||
| 105 | const EMPTY_MESSAGE_MAP: Record<MessageId, ChatMessage> = {}; | ||
| 106 | const EMPTY_ACTIVITY_MAP: Record<string, OrchestrationThreadActivity> = {}; | ||
| 107 | const EMPTY_PROPOSED_PLAN_MAP: Record<string, ProposedPlan> = {}; | ||
| 108 | const EMPTY_TURN_DIFF_MAP: Record<TurnId, TurnDiffSummary> = {}; | ||
| 109 | const EMPTY_THREAD_TURN_STATE: ThreadTurnState = Object.freeze({ latestTurn: null }); | ||
| 110 | 98 | ||
| 111 | function arraysEqual<T>(left: readonly T[], right: readonly T[]): boolean { | 99 | function arraysEqual<T>(left: readonly T[], right: readonly T[]): boolean { |
| 112 | return left.length === right.length && left.every((value, index) => value === right[index]); | 100 | return left.length === right.length && left.every((value, index) => value === right[index]); |
| ... | @@ -403,78 +391,6 @@ function buildTurnDiffSlice(thread: Thread): { | ... | @@ -403,78 +391,6 @@ function buildTurnDiffSlice(thread: Thread): { |
| 403 | }; | 391 | }; |
| 404 | } | 392 | } |
| 405 | 393 | ||
| 406 | function selectThreadMessages(state: EnvironmentState, threadId: ThreadId): ChatMessage[] { | ||
| 407 | const ids = state.messageIdsByThreadId[threadId] ?? EMPTY_MESSAGE_IDS; | ||
| 408 | const byId = state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP; | ||
| 409 | if (ids.length === 0) { | ||
| 410 | return EMPTY_MESSAGES; | ||
| 411 | } | ||
| 412 | return ids.flatMap((id) => { | ||
| 413 | const message = byId[id]; | ||
| 414 | return message ? [message] : []; | ||
| 415 | }); | ||
| 416 | } | ||
| 417 | |||
| 418 | function selectThreadActivities( | ||
| 419 | state: EnvironmentState, | ||
| 420 | threadId: ThreadId, | ||
| 421 | ): OrchestrationThreadActivity[] { | ||
| 422 | const ids = state.activityIdsByThreadId[threadId] ?? EMPTY_ACTIVITY_IDS; | ||
| 423 | const byId = state.activityByThreadId[threadId] ?? EMPTY_ACTIVITY_MAP; | ||
| 424 | if (ids.length === 0) { | ||
| 425 | return EMPTY_ACTIVITIES; | ||
| 426 | } | ||
| 427 | return ids.flatMap((id) => { | ||
| 428 | const activity = byId[id]; | ||
| 429 | return activity ? [activity] : []; | ||
| 430 | }); | ||
| 431 | } | ||
| 432 | |||
| 433 | function selectThreadProposedPlans(state: EnvironmentState, threadId: ThreadId): ProposedPlan[] { | ||
| 434 | const ids = state.proposedPlanIdsByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_IDS; | ||
| 435 | const byId = state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP; | ||
| 436 | if (ids.length === 0) { | ||
| 437 | return EMPTY_PROPOSED_PLANS; | ||
| 438 | } | ||
| 439 | return ids.flatMap((id) => { | ||
| 440 | const plan = byId[id]; | ||
| 441 | return plan ? [plan] : []; | ||
| 442 | }); | ||
| 443 | } | ||
| 444 | |||
| 445 | function selectThreadTurnDiffSummaries( | ||
| 446 | state: EnvironmentState, | ||
| 447 | threadId: ThreadId, | ||
| 448 | ): TurnDiffSummary[] { | ||
| 449 | const ids = state.turnDiffIdsByThreadId[threadId] ?? EMPTY_TURN_IDS; | ||
| 450 | const byId = state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP; | ||
| 451 | if (ids.length === 0) { | ||
| 452 | return EMPTY_TURN_DIFF_SUMMARIES; | ||
| 453 | } | ||
| 454 | return ids.flatMap((id) => { | ||
| 455 | const summary = byId[id]; | ||
| 456 | return summary ? [summary] : []; | ||
| 457 | }); | ||
| 458 | } | ||
| 459 | |||
| 460 | function getThread(state: EnvironmentState, threadId: ThreadId): Thread | undefined { | ||
| 461 | const shell = state.threadShellById[threadId]; | ||
| 462 | if (!shell) { | ||
| 463 | return undefined; | ||
| 464 | } | ||
| 465 | const turnState = state.threadTurnStateById[threadId] ?? EMPTY_THREAD_TURN_STATE; | ||
| 466 | return { | ||
| 467 | ...shell, | ||
| 468 | session: state.threadSessionById[threadId] ?? null, | ||
| 469 | latestTurn: turnState.latestTurn, | ||
| 470 | pendingSourceProposedPlan: turnState.pendingSourceProposedPlan, | ||
| 471 | messages: selectThreadMessages(state, threadId), | ||
| 472 | activities: selectThreadActivities(state, threadId), | ||
| 473 | proposedPlans: selectThreadProposedPlans(state, threadId), | ||
| 474 | turnDiffSummaries: selectThreadTurnDiffSummaries(state, threadId), | ||
| 475 | }; | ||
| 476 | } | ||
| 477 | |||
| 478 | function getProjects(state: EnvironmentState): Project[] { | 394 | function getProjects(state: EnvironmentState): Project[] { |
| 479 | return state.projectIds.flatMap((projectId) => { | 395 | return state.projectIds.flatMap((projectId) => { |
| 480 | const project = state.projectById[projectId]; | 396 | const project = state.projectById[projectId]; |
| ... | @@ -484,7 +400,7 @@ function getProjects(state: EnvironmentState): Project[] { | ... | @@ -484,7 +400,7 @@ function getProjects(state: EnvironmentState): Project[] { |
| 484 | 400 | ||
| 485 | function getThreads(state: EnvironmentState): Thread[] { | 401 | function getThreads(state: EnvironmentState): Thread[] { |
| 486 | return state.threadIds.flatMap((threadId) => { | 402 | return state.threadIds.flatMap((threadId) => { |
| 487 | const thread = getThread(state, threadId); | 403 | const thread = getThreadFromEnvironmentState(state, threadId); |
| 488 | return thread ? [thread] : []; | 404 | return thread ? [thread] : []; |
| 489 | }); | 405 | }); |
| 490 | } | 406 | } |
| ... | @@ -896,7 +812,7 @@ function updateThreadState( | ... | @@ -896,7 +812,7 @@ function updateThreadState( |
| 896 | threadId: ThreadId, | 812 | threadId: ThreadId, |
| 897 | updater: (thread: Thread) => Thread, | 813 | updater: (thread: Thread) => Thread, |
| 898 | ): EnvironmentState { | 814 | ): EnvironmentState { |
| 899 | const currentThread = getThread(state, threadId); | 815 | const currentThread = getThreadFromEnvironmentState(state, threadId); |
| 900 | if (!currentThread) { | 816 | if (!currentThread) { |
| 901 | return state; | 817 | return state; |
| 902 | } | 818 | } |
| ... | @@ -1163,7 +1079,7 @@ function applyEnvironmentOrchestrationEvent( | ... | @@ -1163,7 +1079,7 @@ function applyEnvironmentOrchestrationEvent( |
| 1163 | } | 1079 | } |
| 1164 | 1080 | ||
| 1165 | case "thread.created": { | 1081 | case "thread.created": { |
| 1166 | const previousThread = getThread(state, event.payload.threadId); | 1082 | const previousThread = getThreadFromEnvironmentState(state, event.payload.threadId); |
| 1167 | const nextThread = mapThread( | 1083 | const nextThread = mapThread( |
| 1168 | { | 1084 | { |
| 1169 | id: event.payload.threadId, | 1085 | id: event.payload.threadId, |
| ... | @@ -1669,10 +1585,19 @@ export function selectThreadByRef( | ... | @@ -1669,10 +1585,19 @@ export function selectThreadByRef( |
| 1669 | ref: ScopedThreadRef | null | undefined, | 1585 | ref: ScopedThreadRef | null | undefined, |
| 1670 | ): Thread | undefined { | 1586 | ): Thread | undefined { |
| 1671 | return ref | 1587 | return ref |
| 1672 | ? getThread(selectEnvironmentState(state, ref.environmentId), ref.threadId) | 1588 | ? getThreadFromEnvironmentState(selectEnvironmentState(state, ref.environmentId), ref.threadId) |
| 1673 | : undefined; | 1589 | : undefined; |
| 1674 | } | 1590 | } |
| 1675 | 1591 | ||
| 1592 | export function selectThreadExistsByRef( | ||
| 1593 | state: AppState, | ||
| 1594 | ref: ScopedThreadRef | null | undefined, | ||
| 1595 | ): boolean { | ||
| 1596 | return ref | ||
| 1597 | ? selectEnvironmentState(state, ref.environmentId).threadShellById[ref.threadId] !== undefined | ||
| 1598 | : false; | ||
| 1599 | } | ||
| 1600 | |||
| 1676 | export function selectSidebarThreadSummaryByRef( | 1601 | export function selectSidebarThreadSummaryByRef( |
| 1677 | state: AppState, | 1602 | state: AppState, |
| 1678 | ref: ScopedThreadRef | null | undefined, | 1603 | ref: ScopedThreadRef | null | undefined, |
apps/web/src/storeSelectors.ts+10-120| ... | @@ -1,40 +1,7 @@ | ... | @@ -1,40 +1,7 @@ |
| 1 | import { | 1 | import { type ScopedProjectRef, type ScopedThreadRef, type ThreadId } from "@t3tools/contracts"; |
| 2 | type MessageId, | ||
| 3 | type ScopedProjectRef, | ||
| 4 | type ScopedThreadRef, | ||
| 5 | type ThreadId, | ||
| 6 | type TurnId, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { selectEnvironmentState, type AppState, type EnvironmentState } from "./store"; | 2 | import { selectEnvironmentState, type AppState, type EnvironmentState } from "./store"; |
| 9 | import { | 3 | import { type Project, type SidebarThreadSummary, type Thread } from "./types"; |
| 10 | type ChatMessage, | 4 | import { getThreadFromEnvironmentState } from "./threadDerivation"; |
| 11 | type Project, | ||
| 12 | type ProposedPlan, | ||
| 13 | type SidebarThreadSummary, | ||
| 14 | type Thread, | ||
| 15 | type ThreadSession, | ||
| 16 | type ThreadTurnState, | ||
| 17 | type TurnDiffSummary, | ||
| 18 | } from "./types"; | ||
| 19 | |||
| 20 | const EMPTY_MESSAGES: ChatMessage[] = []; | ||
| 21 | const EMPTY_ACTIVITIES: Thread["activities"] = []; | ||
| 22 | const EMPTY_PROPOSED_PLANS: ProposedPlan[] = []; | ||
| 23 | const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = []; | ||
| 24 | |||
| 25 | function collectByIds<TKey extends string, TValue>( | ||
| 26 | ids: readonly TKey[] | undefined, | ||
| 27 | byId: Record<TKey, TValue> | undefined, | ||
| 28 | ): TValue[] { | ||
| 29 | if (!ids || ids.length === 0 || !byId) { | ||
| 30 | return []; | ||
| 31 | } | ||
| 32 | |||
| 33 | return ids.flatMap((id) => { | ||
| 34 | const value = byId[id]; | ||
| 35 | return value ? [value] : []; | ||
| 36 | }); | ||
| 37 | } | ||
| 38 | 5 | ||
| 39 | export function createProjectSelectorByRef( | 6 | export function createProjectSelectorByRef( |
| 40 | ref: ScopedProjectRef | null | undefined, | 7 | ref: ScopedProjectRef | null | undefined, |
| ... | @@ -55,17 +22,8 @@ export function createSidebarThreadSummarySelectorByRef( | ... | @@ -55,17 +22,8 @@ export function createSidebarThreadSummarySelectorByRef( |
| 55 | function createScopedThreadSelector( | 22 | function createScopedThreadSelector( |
| 56 | resolveRef: (state: AppState) => ScopedThreadRef | null | undefined, | 23 | resolveRef: (state: AppState) => ScopedThreadRef | null | undefined, |
| 57 | ): (state: AppState) => Thread | undefined { | 24 | ): (state: AppState) => Thread | undefined { |
| 58 | let previousShell: EnvironmentState["threadShellById"][ThreadId] | undefined; | 25 | let previousEnvironmentState: EnvironmentState | undefined; |
| 59 | let previousSession: ThreadSession | null | undefined; | 26 | let previousThreadId: ThreadId | undefined; |
| 60 | let previousTurnState: ThreadTurnState | undefined; | ||
| 61 | let previousMessageIds: MessageId[] | undefined; | ||
| 62 | let previousMessagesById: EnvironmentState["messageByThreadId"][ThreadId] | undefined; | ||
| 63 | let previousActivityIds: string[] | undefined; | ||
| 64 | let previousActivitiesById: EnvironmentState["activityByThreadId"][ThreadId] | undefined; | ||
| 65 | let previousProposedPlanIds: string[] | undefined; | ||
| 66 | let previousProposedPlansById: EnvironmentState["proposedPlanByThreadId"][ThreadId] | undefined; | ||
| 67 | let previousTurnDiffIds: TurnId[] | undefined; | ||
| 68 | let previousTurnDiffsById: EnvironmentState["turnDiffSummaryByThreadId"][ThreadId] | undefined; | ||
| 69 | let previousThread: Thread | undefined; | 27 | let previousThread: Thread | undefined; |
| 70 | 28 | ||
| 71 | return (state) => { | 29 | return (state) => { |
| ... | @@ -75,85 +33,17 @@ function createScopedThreadSelector( | ... | @@ -75,85 +33,17 @@ function createScopedThreadSelector( |
| 75 | } | 33 | } |
| 76 | 34 | ||
| 77 | const environmentState = selectEnvironmentState(state, ref.environmentId); | 35 | const environmentState = selectEnvironmentState(state, ref.environmentId); |
| 78 | const threadId = ref.threadId; | ||
| 79 | const shell = environmentState.threadShellById[threadId]; | ||
| 80 | if (!shell) { | ||
| 81 | return undefined; | ||
| 82 | } | ||
| 83 | |||
| 84 | const session = environmentState.threadSessionById[threadId] ?? null; | ||
| 85 | const turnState = environmentState.threadTurnStateById[threadId]; | ||
| 86 | const messageIds = environmentState.messageIdsByThreadId[threadId]; | ||
| 87 | const messageById = environmentState.messageByThreadId[threadId]; | ||
| 88 | const activityIds = environmentState.activityIdsByThreadId[threadId]; | ||
| 89 | const activityById = environmentState.activityByThreadId[threadId]; | ||
| 90 | const proposedPlanIds = environmentState.proposedPlanIdsByThreadId[threadId]; | ||
| 91 | const proposedPlanById = environmentState.proposedPlanByThreadId[threadId]; | ||
| 92 | const turnDiffIds = environmentState.turnDiffIdsByThreadId[threadId]; | ||
| 93 | const turnDiffById = environmentState.turnDiffSummaryByThreadId[threadId]; | ||
| 94 | |||
| 95 | if ( | 36 | if ( |
| 96 | previousThread && | 37 | previousThread && |
| 97 | previousShell === shell && | 38 | previousEnvironmentState === environmentState && |
| 98 | previousSession === session && | 39 | previousThreadId === ref.threadId |
| 99 | previousTurnState === turnState && | ||
| 100 | previousMessageIds === messageIds && | ||
| 101 | previousMessagesById === messageById && | ||
| 102 | previousActivityIds === activityIds && | ||
| 103 | previousActivitiesById === activityById && | ||
| 104 | previousProposedPlanIds === proposedPlanIds && | ||
| 105 | previousProposedPlansById === proposedPlanById && | ||
| 106 | previousTurnDiffIds === turnDiffIds && | ||
| 107 | previousTurnDiffsById === turnDiffById | ||
| 108 | ) { | 40 | ) { |
| 109 | return previousThread; | 41 | return previousThread; |
| 110 | } | 42 | } |
| 111 | 43 | ||
| 112 | const nextThread: Thread = { | 44 | previousEnvironmentState = environmentState; |
| 113 | ...shell, | 45 | previousThreadId = ref.threadId; |
| 114 | session, | 46 | previousThread = getThreadFromEnvironmentState(environmentState, ref.threadId); |
| 115 | latestTurn: turnState?.latestTurn ?? null, | ||
| 116 | pendingSourceProposedPlan: turnState?.pendingSourceProposedPlan, | ||
| 117 | messages: collectByIds(messageIds, messageById) as Thread["messages"] extends ChatMessage[] | ||
| 118 | ? ChatMessage[] | ||
| 119 | : never, | ||
| 120 | activities: collectByIds(activityIds, activityById) as Thread["activities"] extends Array< | ||
| 121 | infer _ | ||
| 122 | > | ||
| 123 | ? Thread["activities"] | ||
| 124 | : never, | ||
| 125 | proposedPlans: collectByIds( | ||
| 126 | proposedPlanIds, | ||
| 127 | proposedPlanById, | ||
| 128 | ) as Thread["proposedPlans"] extends ProposedPlan[] ? ProposedPlan[] : never, | ||
| 129 | turnDiffSummaries: collectByIds( | ||
| 130 | turnDiffIds, | ||
| 131 | turnDiffById, | ||
| 132 | ) as Thread["turnDiffSummaries"] extends TurnDiffSummary[] ? TurnDiffSummary[] : never, | ||
| 133 | }; | ||
| 134 | |||
| 135 | previousShell = shell; | ||
| 136 | previousSession = session; | ||
| 137 | previousTurnState = turnState; | ||
| 138 | previousMessageIds = messageIds; | ||
| 139 | previousMessagesById = messageById; | ||
| 140 | previousActivityIds = activityIds; | ||
| 141 | previousActivitiesById = activityById; | ||
| 142 | previousProposedPlanIds = proposedPlanIds; | ||
| 143 | previousProposedPlansById = proposedPlanById; | ||
| 144 | previousTurnDiffIds = turnDiffIds; | ||
| 145 | previousTurnDiffsById = turnDiffById; | ||
| 146 | previousThread = { | ||
| 147 | ...nextThread, | ||
| 148 | messages: nextThread.messages.length === 0 ? EMPTY_MESSAGES : nextThread.messages, | ||
| 149 | activities: nextThread.activities.length === 0 ? EMPTY_ACTIVITIES : nextThread.activities, | ||
| 150 | proposedPlans: | ||
| 151 | nextThread.proposedPlans.length === 0 ? EMPTY_PROPOSED_PLANS : nextThread.proposedPlans, | ||
| 152 | turnDiffSummaries: | ||
| 153 | nextThread.turnDiffSummaries.length === 0 | ||
| 154 | ? EMPTY_TURN_DIFF_SUMMARIES | ||
| 155 | : nextThread.turnDiffSummaries, | ||
| 156 | }; | ||
| 157 | return previousThread; | 47 | return previousThread; |
| 158 | }; | 48 | }; |
| 159 | } | 49 | } |
apps/web/src/threadDerivation.ts created+152| ... | @@ -0,0 +1,152 @@ | ||
| 1 | import type { MessageId, ThreadId, TurnId } from "@t3tools/contracts"; | ||
| 2 | import type { EnvironmentState } from "./store"; | ||
| 3 | import type { | ||
| 4 | ChatMessage, | ||
| 5 | ProposedPlan, | ||
| 6 | Thread, | ||
| 7 | ThreadSession, | ||
| 8 | ThreadShell, | ||
| 9 | ThreadTurnState, | ||
| 10 | TurnDiffSummary, | ||
| 11 | } from "./types"; | ||
| 12 | |||
| 13 | const EMPTY_MESSAGES: ChatMessage[] = []; | ||
| 14 | const EMPTY_ACTIVITIES: Thread["activities"] = []; | ||
| 15 | const EMPTY_PROPOSED_PLANS: ProposedPlan[] = []; | ||
| 16 | const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = []; | ||
| 17 | const EMPTY_MESSAGE_MAP: Record<MessageId, ChatMessage> = {}; | ||
| 18 | const EMPTY_ACTIVITY_MAP: Record<string, Thread["activities"][number]> = {}; | ||
| 19 | const EMPTY_PROPOSED_PLAN_MAP: Record<string, ProposedPlan> = {}; | ||
| 20 | const EMPTY_TURN_DIFF_MAP: Record<TurnId, TurnDiffSummary> = {}; | ||
| 21 | |||
| 22 | const collectedByIdsCache = new WeakMap<readonly string[], WeakMap<object, readonly unknown[]>>(); | ||
| 23 | const threadCache = new WeakMap< | ||
| 24 | ThreadShell, | ||
| 25 | { | ||
| 26 | session: ThreadSession | null; | ||
| 27 | turnState: ThreadTurnState | undefined; | ||
| 28 | messages: Thread["messages"]; | ||
| 29 | activities: Thread["activities"]; | ||
| 30 | proposedPlans: Thread["proposedPlans"]; | ||
| 31 | turnDiffSummaries: Thread["turnDiffSummaries"]; | ||
| 32 | thread: Thread; | ||
| 33 | } | ||
| 34 | >(); | ||
| 35 | |||
| 36 | function collectByIds<TKey extends string, TValue>( | ||
| 37 | ids: readonly TKey[] | undefined, | ||
| 38 | byId: Record<TKey, TValue> | undefined, | ||
| 39 | emptyValue: TValue[], | ||
| 40 | ): TValue[] { | ||
| 41 | if (!ids || ids.length === 0 || !byId) { | ||
| 42 | return emptyValue; | ||
| 43 | } | ||
| 44 | |||
| 45 | const cachedByRecord = collectedByIdsCache.get(ids); | ||
| 46 | const cached = cachedByRecord?.get(byId); | ||
| 47 | if (cached) { | ||
| 48 | return cached as TValue[]; | ||
| 49 | } | ||
| 50 | |||
| 51 | const nextValues = ids.flatMap((id) => { | ||
| 52 | const value = byId[id]; | ||
| 53 | return value ? [value] : []; | ||
| 54 | }); | ||
| 55 | const nextCachedByRecord = cachedByRecord ?? new WeakMap<object, readonly unknown[]>(); | ||
| 56 | nextCachedByRecord.set(byId, nextValues); | ||
| 57 | if (!cachedByRecord) { | ||
| 58 | collectedByIdsCache.set(ids, nextCachedByRecord); | ||
| 59 | } | ||
| 60 | return nextValues; | ||
| 61 | } | ||
| 62 | |||
| 63 | function selectThreadMessages(state: EnvironmentState, threadId: ThreadId): Thread["messages"] { | ||
| 64 | return collectByIds( | ||
| 65 | state.messageIdsByThreadId[threadId], | ||
| 66 | state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP, | ||
| 67 | EMPTY_MESSAGES, | ||
| 68 | ); | ||
| 69 | } | ||
| 70 | |||
| 71 | function selectThreadActivities(state: EnvironmentState, threadId: ThreadId): Thread["activities"] { | ||
| 72 | return collectByIds( | ||
| 73 | state.activityIdsByThreadId[threadId], | ||
| 74 | state.activityByThreadId[threadId] ?? EMPTY_ACTIVITY_MAP, | ||
| 75 | EMPTY_ACTIVITIES, | ||
| 76 | ); | ||
| 77 | } | ||
| 78 | |||
| 79 | function selectThreadProposedPlans( | ||
| 80 | state: EnvironmentState, | ||
| 81 | threadId: ThreadId, | ||
| 82 | ): Thread["proposedPlans"] { | ||
| 83 | return collectByIds( | ||
| 84 | state.proposedPlanIdsByThreadId[threadId], | ||
| 85 | state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP, | ||
| 86 | EMPTY_PROPOSED_PLANS, | ||
| 87 | ); | ||
| 88 | } | ||
| 89 | |||
| 90 | function selectThreadTurnDiffSummaries( | ||
| 91 | state: EnvironmentState, | ||
| 92 | threadId: ThreadId, | ||
| 93 | ): Thread["turnDiffSummaries"] { | ||
| 94 | return collectByIds( | ||
| 95 | state.turnDiffIdsByThreadId[threadId], | ||
| 96 | state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP, | ||
| 97 | EMPTY_TURN_DIFF_SUMMARIES, | ||
| 98 | ); | ||
| 99 | } | ||
| 100 | |||
| 101 | export function getThreadFromEnvironmentState( | ||
| 102 | state: EnvironmentState, | ||
| 103 | threadId: ThreadId, | ||
| 104 | ): Thread | undefined { | ||
| 105 | const shell = state.threadShellById[threadId]; | ||
| 106 | if (!shell) { | ||
| 107 | return undefined; | ||
| 108 | } | ||
| 109 | |||
| 110 | const session = state.threadSessionById[threadId] ?? null; | ||
| 111 | const turnState = state.threadTurnStateById[threadId]; | ||
| 112 | const messages = selectThreadMessages(state, threadId); | ||
| 113 | const activities = selectThreadActivities(state, threadId); | ||
| 114 | const proposedPlans = selectThreadProposedPlans(state, threadId); | ||
| 115 | const turnDiffSummaries = selectThreadTurnDiffSummaries(state, threadId); | ||
| 116 | const cached = threadCache.get(shell); | ||
| 117 | |||
| 118 | if ( | ||
| 119 | cached && | ||
| 120 | cached.session === session && | ||
| 121 | cached.turnState === turnState && | ||
| 122 | cached.messages === messages && | ||
| 123 | cached.activities === activities && | ||
| 124 | cached.proposedPlans === proposedPlans && | ||
| 125 | cached.turnDiffSummaries === turnDiffSummaries | ||
| 126 | ) { | ||
| 127 | return cached.thread; | ||
| 128 | } | ||
| 129 | |||
| 130 | const thread: Thread = { | ||
| 131 | ...shell, | ||
| 132 | session, | ||
| 133 | latestTurn: turnState?.latestTurn ?? null, | ||
| 134 | pendingSourceProposedPlan: turnState?.pendingSourceProposedPlan, | ||
| 135 | messages, | ||
| 136 | activities, | ||
| 137 | proposedPlans, | ||
| 138 | turnDiffSummaries, | ||
| 139 | }; | ||
| 140 | |||
| 141 | threadCache.set(shell, { | ||
| 142 | session, | ||
| 143 | turnState, | ||
| 144 | messages, | ||
| 145 | activities, | ||
| 146 | proposedPlans, | ||
| 147 | turnDiffSummaries, | ||
| 148 | thread, | ||
| 149 | }); | ||
| 150 | |||
| 151 | return thread; | ||
| 152 | } | ||