| 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 | 310 | | { |
| 311 | 311 | environmentId: EnvironmentId; |
| 312 | 312 | threadId: ThreadId; |
| 313 | onDiffPanelOpen?: () => void; | |
| 313 | 314 | routeKind: "server"; |
| 314 | 315 | draftId?: never; |
| 315 | 316 | } |
| 316 | 317 | | { |
| 317 | 318 | environmentId: EnvironmentId; |
| 318 | 319 | threadId: ThreadId; |
| 320 | onDiffPanelOpen?: () => void; | |
| 319 | 321 | routeKind: "draft"; |
| 320 | 322 | draftId: DraftId; |
| 321 | 323 | }; |
| ... | ... | @@ -569,7 +571,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 569 | 571 | }); |
| 570 | 572 | |
| 571 | 573 | export default function ChatView(props: ChatViewProps) { |
| 572 | const { environmentId, threadId, routeKind } = props; | |
| 574 | const { environmentId, threadId, routeKind, onDiffPanelOpen } = props; | |
| 573 | 575 | const draftId = routeKind === "draft" ? props.draftId : null; |
| 574 | 576 | const routeThreadRef = useMemo( |
| 575 | 577 | () => scopeThreadRef(environmentId, threadId), |
| ... | ... | @@ -1470,6 +1472,9 @@ export default function ChatView(props: ChatViewProps) { |
| 1470 | 1472 | if (!isServerThread) { |
| 1471 | 1473 | return; |
| 1472 | 1474 | } |
| 1475 | if (!diffOpen) { | |
| 1476 | onDiffPanelOpen?.(); | |
| 1477 | } | |
| 1473 | 1478 | void navigate({ |
| 1474 | 1479 | to: "/$environmentId/$threadId", |
| 1475 | 1480 | params: { |
| ... | ... | @@ -1482,7 +1487,7 @@ export default function ChatView(props: ChatViewProps) { |
| 1482 | 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 | 1492 | const envLocked = Boolean( |
| 1488 | 1493 | activeThread && |
| ... | ... | @@ -3245,6 +3250,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3245 | 3250 | if (!isServerThread) { |
| 3246 | 3251 | return; |
| 3247 | 3252 | } |
| 3253 | onDiffPanelOpen?.(); | |
| 3248 | 3254 | void navigate({ |
| 3249 | 3255 | to: "/$environmentId/$threadId", |
| 3250 | 3256 | params: { |
| ... | ... | @@ -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 | 3270 | const onRevertUserMessage = useCallback( |
| 3265 | 3271 | (messageId: MessageId) => { |
apps/web/src/routes/_chat.$environmentId.$threadId.tsx+26-10| ... | ... | @@ -17,7 +17,7 @@ import { |
| 17 | 17 | stripDiffSearchParams, |
| 18 | 18 | } from "../diffRouteSearch"; |
| 19 | 19 | import { useMediaQuery } from "../hooks/useMediaQuery"; |
| 20 | import { selectEnvironmentState, selectThreadByRef, useStore } from "../store"; | |
| 20 | import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store"; | |
| 21 | 21 | import { createThreadSelectorByRef } from "../storeSelectors"; |
| 22 | 22 | import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes"; |
| 23 | 23 | import { Sheet, SheetPopup } from "../components/ui/sheet"; |
| ... | ... | @@ -172,7 +172,7 @@ function ChatThreadRouteView() { |
| 172 | 172 | (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).bootstrapComplete, |
| 173 | 173 | ); |
| 174 | 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 | 176 | const environmentHasServerThreads = useStore( |
| 177 | 177 | (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).threadIds.length > 0, |
| 178 | 178 | ); |
| ... | ... | @@ -193,7 +193,26 @@ function ChatThreadRouteView() { |
| 193 | 193 | const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; |
| 194 | 194 | const diffOpen = search.diff === "1"; |
| 195 | 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 | 216 | const closeDiff = useCallback(() => { |
| 198 | 217 | if (!threadRef) { |
| 199 | 218 | return; |
| ... | ... | @@ -208,6 +227,7 @@ function ChatThreadRouteView() { |
| 208 | 227 | if (!threadRef) { |
| 209 | 228 | return; |
| 210 | 229 | } |
| 230 | markDiffOpened(); | |
| 211 | 231 | void navigate({ |
| 212 | 232 | to: "/$environmentId/$threadId", |
| 213 | 233 | params: buildThreadRouteParams(threadRef), |
| ... | ... | @@ -216,13 +236,7 @@ function ChatThreadRouteView() { |
| 216 | 236 | return { ...rest, diff: "1" }; |
| 217 | 237 | }, |
| 218 | 238 | }); |
| 219 | }, [navigate, threadRef]); | |
| 220 | ||
| 221 | useEffect(() => { | |
| 222 | if (diffOpen) { | |
| 223 | setHasOpenedDiff(true); | |
| 224 | } | |
| 225 | }, [diffOpen]); | |
| 239 | }, [markDiffOpened, navigate, threadRef]); | |
| 226 | 240 | |
| 227 | 241 | useEffect(() => { |
| 228 | 242 | if (!threadRef || !bootstrapComplete) { |
| ... | ... | @@ -254,6 +268,7 @@ function ChatThreadRouteView() { |
| 254 | 268 | <ChatView |
| 255 | 269 | environmentId={threadRef.environmentId} |
| 256 | 270 | threadId={threadRef.threadId} |
| 271 | onDiffPanelOpen={markDiffOpened} | |
| 257 | 272 | routeKind="server" |
| 258 | 273 | /> |
| 259 | 274 | </SidebarInset> |
| ... | ... | @@ -273,6 +288,7 @@ function ChatThreadRouteView() { |
| 273 | 288 | <ChatView |
| 274 | 289 | environmentId={threadRef.environmentId} |
| 275 | 290 | threadId={threadRef.threadId} |
| 291 | onDiffPanelOpen={markDiffOpened} | |
| 276 | 292 | routeKind="server" |
| 277 | 293 | /> |
| 278 | 294 | </SidebarInset> |
apps/web/src/store.test.ts+124| ... | ... | @@ -18,6 +18,8 @@ import { |
| 18 | 18 | applyOrchestrationEvents, |
| 19 | 19 | selectEnvironmentState, |
| 20 | 20 | selectProjectsAcrossEnvironments, |
| 21 | selectThreadByRef, | |
| 22 | selectThreadExistsByRef, | |
| 21 | 23 | setThreadBranch, |
| 22 | 24 | selectThreadsAcrossEnvironments, |
| 23 | 25 | syncServerReadModel, |
| ... | ... | @@ -245,6 +247,128 @@ function makeEvent<T extends OrchestrationEvent["type"]>( |
| 245 | 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 | 372 | function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads"][number]>) { |
| 249 | 373 | return { |
| 250 | 374 | id: ThreadId.make("thread-1"), |
apps/web/src/store.ts+14-89| ... | ... | @@ -38,6 +38,7 @@ import { |
| 38 | 38 | } from "./types"; |
| 39 | 39 | import { resolveEnvironmentHttpUrl } from "./environments/runtime"; |
| 40 | 40 | import { sanitizeThreadErrorMessage } from "./rpc/transportError"; |
| 41 | import { getThreadFromEnvironmentState } from "./threadDerivation"; | |
| 41 | 42 | |
| 42 | 43 | export interface EnvironmentState { |
| 43 | 44 | projectIds: ProjectId[]; |
| ... | ... | @@ -94,19 +95,6 @@ const MAX_THREAD_CHECKPOINTS = 500; |
| 94 | 95 | const MAX_THREAD_PROPOSED_PLANS = 200; |
| 95 | 96 | const MAX_THREAD_ACTIVITIES = 500; |
| 96 | 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 | 99 | function arraysEqual<T>(left: readonly T[], right: readonly T[]): boolean { |
| 112 | 100 | return left.length === right.length && left.every((value, index) => value === right[index]); |
| ... | ... | @@ -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 | 394 | function getProjects(state: EnvironmentState): Project[] { |
| 479 | 395 | return state.projectIds.flatMap((projectId) => { |
| 480 | 396 | const project = state.projectById[projectId]; |
| ... | ... | @@ -484,7 +400,7 @@ function getProjects(state: EnvironmentState): Project[] { |
| 484 | 400 | |
| 485 | 401 | function getThreads(state: EnvironmentState): Thread[] { |
| 486 | 402 | return state.threadIds.flatMap((threadId) => { |
| 487 | const thread = getThread(state, threadId); | |
| 403 | const thread = getThreadFromEnvironmentState(state, threadId); | |
| 488 | 404 | return thread ? [thread] : []; |
| 489 | 405 | }); |
| 490 | 406 | } |
| ... | ... | @@ -896,7 +812,7 @@ function updateThreadState( |
| 896 | 812 | threadId: ThreadId, |
| 897 | 813 | updater: (thread: Thread) => Thread, |
| 898 | 814 | ): EnvironmentState { |
| 899 | const currentThread = getThread(state, threadId); | |
| 815 | const currentThread = getThreadFromEnvironmentState(state, threadId); | |
| 900 | 816 | if (!currentThread) { |
| 901 | 817 | return state; |
| 902 | 818 | } |
| ... | ... | @@ -1163,7 +1079,7 @@ function applyEnvironmentOrchestrationEvent( |
| 1163 | 1079 | } |
| 1164 | 1080 | |
| 1165 | 1081 | case "thread.created": { |
| 1166 | const previousThread = getThread(state, event.payload.threadId); | |
| 1082 | const previousThread = getThreadFromEnvironmentState(state, event.payload.threadId); | |
| 1167 | 1083 | const nextThread = mapThread( |
| 1168 | 1084 | { |
| 1169 | 1085 | id: event.payload.threadId, |
| ... | ... | @@ -1669,10 +1585,19 @@ export function selectThreadByRef( |
| 1669 | 1585 | ref: ScopedThreadRef | null | undefined, |
| 1670 | 1586 | ): Thread | undefined { |
| 1671 | 1587 | return ref |
| 1672 | ? getThread(selectEnvironmentState(state, ref.environmentId), ref.threadId) | |
| 1588 | ? getThreadFromEnvironmentState(selectEnvironmentState(state, ref.environmentId), ref.threadId) | |
| 1673 | 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 | 1601 | export function selectSidebarThreadSummaryByRef( |
| 1677 | 1602 | state: AppState, |
| 1678 | 1603 | ref: ScopedThreadRef | null | undefined, |
apps/web/src/storeSelectors.ts+10-120| ... | ... | @@ -1,40 +1,7 @@ |
| 1 | import { | |
| 2 | type MessageId, | |
| 3 | type ScopedProjectRef, | |
| 4 | type ScopedThreadRef, | |
| 5 | type ThreadId, | |
| 6 | type TurnId, | |
| 7 | } from "@t3tools/contracts"; | |
| 1 | import { type ScopedProjectRef, type ScopedThreadRef, type ThreadId } from "@t3tools/contracts"; | |
| 8 | 2 | import { selectEnvironmentState, type AppState, type EnvironmentState } from "./store"; |
| 9 | import { | |
| 10 | type ChatMessage, | |
| 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 | } | |
| 3 | import { type Project, type SidebarThreadSummary, type Thread } from "./types"; | |
| 4 | import { getThreadFromEnvironmentState } from "./threadDerivation"; | |
| 38 | 5 | |
| 39 | 6 | export function createProjectSelectorByRef( |
| 40 | 7 | ref: ScopedProjectRef | null | undefined, |
| ... | ... | @@ -55,17 +22,8 @@ export function createSidebarThreadSummarySelectorByRef( |
| 55 | 22 | function createScopedThreadSelector( |
| 56 | 23 | resolveRef: (state: AppState) => ScopedThreadRef | null | undefined, |
| 57 | 24 | ): (state: AppState) => Thread | undefined { |
| 58 | let previousShell: EnvironmentState["threadShellById"][ThreadId] | undefined; | |
| 59 | let previousSession: ThreadSession | null | 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; | |
| 25 | let previousEnvironmentState: EnvironmentState | undefined; | |
| 26 | let previousThreadId: ThreadId | undefined; | |
| 69 | 27 | let previousThread: Thread | undefined; |
| 70 | 28 | |
| 71 | 29 | return (state) => { |
| ... | ... | @@ -75,85 +33,17 @@ function createScopedThreadSelector( |
| 75 | 33 | } |
| 76 | 34 | |
| 77 | 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 | 36 | if ( |
| 96 | 37 | previousThread && |
| 97 | previousShell === shell && | |
| 98 | previousSession === session && | |
| 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 | |
| 38 | previousEnvironmentState === environmentState && | |
| 39 | previousThreadId === ref.threadId | |
| 108 | 40 | ) { |
| 109 | 41 | return previousThread; |
| 110 | 42 | } |
| 111 | 43 | |
| 112 | const nextThread: Thread = { | |
| 113 | ...shell, | |
| 114 | session, | |
| 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 | }; | |
| 44 | previousEnvironmentState = environmentState; | |
| 45 | previousThreadId = ref.threadId; | |
| 46 | previousThread = getThreadFromEnvironmentState(environmentState, ref.threadId); | |
| 157 | 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 | } |