History

Complete orchestration V2 frontend cutover

- Migrate web and mobile state, commands, and persistence to V2 projections - Render conversations from authoritative visible turn items - Harden runtime policy, launch handling, attachments, and cache invalidation

Julius Marminge committed 4 months ago

Commit 3991a001 parent 75e1269111 files · +6259 −9197Browse files

Unified · Split

111 files
  1. .plans/21-orchestration-v2-application-integration.md +162 −95
  2. apps/mobile/src/connection/storage.ts +249 −1
  3. apps/mobile/src/features/archive/archivedThreadList.test.ts +15 −25
  4. apps/mobile/src/features/home/HomeScreen.tsx +57 −28
  5. apps/mobile/src/features/home/homeThreadList.test.ts +4 −11
  6. apps/mobile/src/features/review/reviewModel.test.ts +7 −11
  7. apps/mobile/src/features/review/reviewModel.ts +11 −10
  8. apps/mobile/src/features/review/useReviewSections.ts +3 −2
  9. apps/mobile/src/features/threads/PendingApprovalCard.tsx +14 −6
  10. apps/mobile/src/features/threads/PendingUserInputCard.tsx +16 −9
  11. apps/mobile/src/features/threads/ThreadComposer.tsx +6 −11
  12. apps/mobile/src/features/threads/ThreadDetailScreen.tsx +51 −44
  13. apps/mobile/src/features/threads/ThreadFeed.tsx +51 −58
  14. apps/mobile/src/features/threads/ThreadRouteScreen.tsx +10 −20
  15. apps/mobile/src/features/threads/threadPresentation.ts +6 −49
  16. apps/mobile/src/features/threads/use-project-actions.ts +10 −9
  17. apps/mobile/src/lib/repositoryGroups.test.ts +3 −6
  18. apps/mobile/src/lib/scopedEntities.ts +2 −2
  19. apps/mobile/src/lib/threadActivity.test.ts +79 −433
  20. apps/mobile/src/lib/threadActivity.ts +214 −1186
  21. apps/mobile/src/state/queries.ts +3 −8
  22. apps/mobile/src/state/threads.ts +10 −2
  23. apps/mobile/src/state/use-selected-thread-requests.ts +23 −17
  24. apps/mobile/src/state/use-thread-composer-state.ts +6 −7
  25. apps/mobile/src/state/use-thread-outbox-drain.ts +5 −4
  26. apps/mobile/src/test-fixtures.ts +130 −0
  27. apps/server/README.md +3 −0
  28. apps/server/src/attachmentStore.test.ts +11 −0
  29. apps/server/src/attachmentStore.ts +11 −0
  30. apps/server/src/orchestration-v2/FoundationPersistence.test.ts +30 −0
  31. apps/server/src/orchestration-v2/Orchestrator.ts +54 −0
  32. apps/server/src/orchestration-v2/ProjectionStore.ts +44 −0
  33. apps/server/src/orchestration-v2/RuntimePolicy.test.ts +99 −0
  34. apps/server/src/orchestration-v2/RuntimePolicy.ts +56 −3
  35. apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +147 −6
  36. apps/server/src/orchestration-v2/ThreadLaunchService.ts +125 −31
  37. apps/server/src/orchestration-v2/runtimeLayer.ts +11 −7
  38. apps/server/src/ws.ts +199 −28
  39. apps/web/src/components/BranchToolbarBranchSelector.tsx +2 −0
  40. apps/web/src/components/ChatView.logic.test.ts +29 −44
  41. apps/web/src/components/ChatView.logic.ts +75 −69
  42. apps/web/src/components/ChatView.tsx +194 −267
  43. apps/web/src/components/CommandPalette.logic.test.ts +5 −6
  44. apps/web/src/components/DiffPanel.tsx +40 −45
  45. apps/web/src/components/Sidebar.logic.test.ts +34 −44
  46. apps/web/src/components/Sidebar.logic.ts +8 −8
  47. apps/web/src/components/Sidebar.tsx +5 −4
  48. apps/web/src/components/chat/ChangedFilesTree.test.tsx +3 −3
  49. apps/web/src/components/chat/ChangedFilesTree.tsx +27 −55
  50. apps/web/src/components/chat/ChatComposer.tsx +40 −16
  51. apps/web/src/components/chat/ComposerPendingApprovalActions.tsx +9 −7
  52. apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx +5 −11
  53. apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +17 −7
  54. apps/web/src/components/chat/MessagesTimeline.logic.test.ts +128 −55
  55. apps/web/src/components/chat/MessagesTimeline.logic.ts +78 −51
  56. apps/web/src/components/chat/MessagesTimeline.test.tsx +116 −6
  57. apps/web/src/components/chat/MessagesTimeline.tsx +283 −75
  58. apps/web/src/components/settings/SettingsPanels.tsx +2 −4
  59. apps/web/src/connection/storage.ts +36 −51
  60. apps/web/src/diffPanelStore.test.ts +5 −5
  61. apps/web/src/diffPanelStore.ts +5 −5
  62. apps/web/src/historyBootstrap.test.ts +7 −7
  63. apps/web/src/hooks/useThreadActions.ts +2 −2
  64. apps/web/src/hooks/useTurnDiffSummaries.ts +4 −4
  65. apps/web/src/lib/contextWindow.test.ts +35 −75
  66. apps/web/src/lib/contextWindow.ts +23 −31
  67. apps/web/src/lib/threadSort.test.ts +8 −9
  68. apps/web/src/session-logic.test.ts +123 −1652
  69. apps/web/src/session-logic.ts +392 −1244
  70. apps/web/src/state/entities.ts +34 −29
  71. apps/web/src/state/queries.ts +3 −10
  72. apps/web/src/state/threads.ts +10 −2
  73. apps/web/src/test-fixtures.ts +89 −0
  74. apps/web/src/types.ts +11 −12
  75. apps/web/src/worktreeCleanup.test.ts +5 −6
  76. packages/client-runtime/src/connection/registry.test.ts +4 −5
  77. packages/client-runtime/src/operations/commands.test.ts +225 −71
  78. packages/client-runtime/src/operations/commands.ts +484 −236
  79. packages/client-runtime/src/platform/index.ts +1 −0
  80. packages/client-runtime/src/platform/orchestrationCache.test.ts +59 −0
  81. packages/client-runtime/src/platform/orchestrationCache.ts +32 −0
  82. packages/client-runtime/src/platform/persistence.ts +6 −10
  83. packages/client-runtime/src/rpc/client.ts +3 −7
  84. packages/client-runtime/src/state/archivedThreads.test.ts +2 −2
  85. packages/client-runtime/src/state/archivedThreads.ts +3 −3
  86. packages/client-runtime/src/state/entities.test.ts +101 −356
  87. packages/client-runtime/src/state/models.ts +705 −15
  88. packages/client-runtime/src/state/orchestration.ts +4 −4
  89. packages/client-runtime/src/state/orchestrationV2Projection.test.ts +154 −0
  90. packages/client-runtime/src/state/orchestrationV2Projection.ts +34 −9
  91. packages/client-runtime/src/state/orchestrationV2TestFixtures.ts +104 −0
  92. packages/client-runtime/src/state/projectEntities.ts +2 −2
  93. packages/client-runtime/src/state/shell-sync.test.ts +9 −19
  94. packages/client-runtime/src/state/shell.test.ts +10 −2
  95. packages/client-runtime/src/state/shell.ts +31 −62
  96. packages/client-runtime/src/state/shellReducer.test.ts +52 −153
  97. packages/client-runtime/src/state/shellReducer.ts +43 −28
  98. packages/client-runtime/src/state/snapshots.ts +2 −2
  99. packages/client-runtime/src/state/threadCommands.ts +1 −0
  100. packages/client-runtime/src/state/threadDetail.test.ts +45 −0
  101. packages/client-runtime/src/state/threadDetail.ts +104 −117
  102. packages/client-runtime/src/state/threadReducer.test.ts +0 −767
  103. packages/client-runtime/src/state/threadReducer.ts +0 −599
  104. packages/client-runtime/src/state/threadShell.ts +10 −10
  105. packages/client-runtime/src/state/threads-sync.test.ts +64 −562
  106. packages/client-runtime/src/state/threads.ts +45 −88
  107. packages/contracts/src/chatAttachment.ts +21 −1
  108. packages/contracts/src/orchestrationV2.ts +22 −2
  109. packages/contracts/src/project.ts +11 −0
  110. packages/contracts/src/rpc.ts +22 −0
  111. packages/shared/src/orchestrationTiming.ts +15 −15
.plans/21-orchestration-v2-application-integration.md+162−95
@@ -20,14 +20,15 @@ Execution rules: · 19 unmodified lines
2020 - Shape 3 performs the backend hard cut and intentionally breaks the old client protocol.
2121 - Shape 4.0 updates web and mobile to the final protocol while preserving the current product experience and component structure.
2222 - Shape 4.5 enriches that working application with V2-native graph, execution, tool, and context information.
23−- Do not create a server-side V2-to-V1 compatibility projection. Client presentation adapters may generalize existing component props, but the source of truth remains the complete V2 projection.
23+- Do not create a server-side V2-to-V1 compatibility projection. Shape 4.0 may use temporary parity adapters around existing component props, but Shape 4.5 must not grow those adapters into a second complete thread/projection model.
24+- Keep React bindings platform-owned. Web and mobile may have intentionally similar binding and presentation modules; shared client-runtime ownership applies to projection state and domain semantics, not to React hooks or native UI policy.
2425 - Only the completed Shape 4.5 system is eligible for a fresh-state user-facing release. Shipping an upgrade to an installation with V1 threads additionally requires the separate Stage 5 migration decision.
2526 - Do not drop V1 tables, migrations, provider runtime rows, or attachment files during this plan.
2627 - V1 removal happens inside the shape that replaces it; there is no deferred general cleanup phase.
2728
2829 ## Alignment With The Current Production Path
2930
30−At the start of this plan, the application path was V1 at the orchestration boundary. After the completed Shape 3, the server is V2-only while the ordinary web and mobile clients still call the removed V1 protocol. Several provider and selection concepts beneath that boundary were already provider-neutral and have been retained. Shapes 4.0 and 4.5 must continue reusing them rather than recreating the existing model, mode, and provider-instance UX.
31+At the start of this plan, the application path was V1 at the orchestration boundary. Between completed Shapes 3 and 4.0, the server was V2-only while the ordinary web and mobile clients still called the removed V1 protocol. Shape 4.0 has now closed that temporary gap. Several provider and selection concepts beneath that boundary were already provider-neutral and have been retained. Shape 4.5 must continue reusing them rather than recreating the existing model, mode, and provider-instance UX.
3132
3233 The historical pre-cut flow was:
3334
@@ -40,21 +41,22 @@ Web/mobile model and mode selection · 6 unmodified lines
4041 -> driver-specific legacy adapter
4142 ```
4243
43−The current branch boundary is:
44+The current post-Shape-4.0 boundary is:
4445
4546 ```text
4647 ordinary web/mobile state and commands
47− -> orchestrationV1.* RPCs
48− -> no server handler after the Shape 3 hard cut
49−
50−V2 debug client, CLI, MCP, and backend consumers
48+ -> shared client-runtime V2 shell and thread projections
5149 -> final orchestration.* RPCs / V2 application services
5250 -> shared application event transaction
5351 -> Agent Orchestrator V2
5452 -> provider execution
53+
54+V2 debug client
55+ -> direct V2 projection and item inspection
56+ -> reference presentation for Shape 4.5 production enrichment
5557 ```
5658
57−Shape 4.0 closes this intentional client/server gap. Shape 4.5 enriches the resulting working frontend.
59+Shape 4.0 closed the intentional client/server gap. Shape 4.5 replaces temporary parity-oriented chat shaping with direct, scoped consumption of the complete V2 projection and its server-authoritative visible item sequence.
5860
5961 Current ownership and intended treatment:
6062
@@ -315,7 +317,7 @@ Add coverage for: · 254 unmodified lines
315317
316318 ## Shape 2: Application Services Around V2
317319
318−Implementation status: complete. Shape 3 completed the backend protocol cut; the production web/mobile cutover remains intentionally deferred to Shape 4.0.
320+Implementation status: complete. Shape 3 completed the backend protocol cut; the production web/mobile cutover was subsequently completed in Shape 4.0.
319321
320322 ### Goal
321323
@@ -702,7 +704,16 @@ Add backend integration coverage for: · 380 unmodified lines
702704
703705 ## Shape 4.0: V2 Frontend Cutover And Product Parity
704706
705−Implementation status: pending.
707+Implementation status: complete.
708+
709+Implementation outcome:
710+
711+- Web and mobile now consume the unified V2 shell and full V2 thread projections through the shared client runtime, including reconnect/cursor handling and versioned V2-only caches.
712+- Ordinary project, thread, message, run-control, runtime-request, settings, archive, delete, checkpoint, attachment, and plan-implementation commands now use the final event-sourced project and V2 orchestration transports.
713+- Existing root-workspace, newly provisioned worktree, origin-based worktree, and already-prepared worktree launch flows are preserved, including promotion of an empty server thread into a worktree before its first message.
714+- Existing web and mobile product components consume V2-native parity models. The complete projection and structured turn-item payload remain available, while unsupported rich V2 visualization stays deferred to Shape 4.5.
715+- The parity models are an explicit temporary bridge for existing product components. They are not the intended Shape 4.5 thread API and may be removed incrementally as direct item consumers replace them.
716+- Production V1 client reducers, subscriptions, command paths, and persistence writers have been removed. Legacy server state remains untouched for the separate Stage 5 migration decision.
706717
707718 ### Goal
708719
@@ -716,6 +727,8 @@ At the end of Shape 4.0, the ordinary application—not only the V2 debug route · 7 unmodified lines
716727 - Reuse existing sidebar, chat, composer, approval, archive, diff, settings, and model-picker components where their behavior remains correct.
717728 - Generalize component props around V2-native view models instead of manufacturing V1 commands, events, activities, or thread projections.
718729 - Retain the complete V2 projection in client state even when the parity UI initially presents only a subset of it.
730+- Preserve shell and thread-detail state as separately subscribable streams. Shape 4.0 components may compose them locally where required, but the client runtime must not make detail traffic a dependency of shell/sidebar state.
731+- Keep web and mobile React bindings separate. Similar binding code is intentional and is not a Shape 4.0 deduplication target.
719732 - Provide a safe generic renderer for V2 turn-item types that do not yet have a rich presentation. Do not drop structured tool or execution data merely because Shape 4.5 owns the final renderer.
720733 - Defer graph visualization, rich execution inspection, and provider-native tool presentation to Shape 4.5.
721734 - Do not import V1 server rows or migrate existing V1 threads in this shape.
@@ -735,7 +748,7 @@ The server's committed application sequence is the shell reconnect boundary. A t · 13 unmodified lines
735748
736749 Bump web and mobile orchestration-cache versions. Discard cached V1 shell/thread projections while preserving drafts, credentials, environment configuration, preferences, and unrelated local state. Cache invalidation is not the Stage 5 server-state migration.
737750
738−### 2. Add a parity-focused V2 presentation adapter
751+### 2. Add a temporary parity-focused V2 presentation adapter
739752
740753 Create shared, V2-native client view models for the existing application surfaces, including:
741754
@@ -750,7 +763,7 @@ Create shared, V2-native client view models for the existing application surface · 8 unmodified lines
750763
751764 Derive these models from the V2 thread projection, `visibleTurnItems`, runs, attempts, runtime requests, and checkpoints. Existing components may be adapted to consume these models, but the adapter must not recreate an `OrchestrationThread`, `OrchestrationThreadActivity`, V1 `latestTurn`, or a V1 event stream.
752765
753−Web and mobile share the projection reducer and parity derivation rules. Platform-specific components may render the resulting view models differently.
766+Web and mobile share the projection reducer and retain the complete structured V2 data. Platform-specific components may render temporary parity views differently. Shape 4.5 owns replacing full-thread parity shaping with direct, scoped item consumption; Shape 4.0 does not need to pre-design that final API.
754767
755768 ### 3. Replace production client commands
756769
@@ -847,6 +860,9 @@ Add web and mobile integration coverage for: · 90 unmodified lines
847860 - A user can create a project and thread, converse with an agent, reconnect, respond to requests, inspect diffs, archive, and delete.
848861 - Existing V1-era product behavior that remains supported by V2 is present without routing through V1 data shapes.
849862 - Full V2 projections and structured turn-item payloads are retained even when rendered generically.
863+- Server-projected `visibleTurnItems`, including inherited and synthetic rows, remain intact in client state even when Shape 4.0 surfaces do not render them richly.
864+- Shell/sidebar state remains independently subscribable from full thread detail.
865+- Temporary full-thread parity models and separate web/mobile React binding modules are allowed at this boundary; their final cleanup belongs to Shape 4.5.
850866 - No production code calls an `orchestrationV1.*` endpoint.
851867 - No V2-to-V1 server or persistence compatibility projection exists.
852868 - The V2 debug route is no longer the only functional V2 client.
@@ -855,139 +871,189 @@ Add web and mobile integration coverage for: · 2 unmodified lines
855871
856872 ## Shape 4.5: V2-Native Frontend Enrichment
857873
858−Implementation status: pending.
874+Implementation status: in progress. Sections 1 and 2 are implemented for web; mobile cutover and the deeper supporting-state, graph, workflow, and parity-removal sections remain.
859875
860876 ### Goal
861877
862−Enrich the now-working application with information and workflows that V2 models explicitly but the V1 UI could not represent well. This shape builds on the Shape 4.0 state and command path; it must not introduce a second projection model or replace working frontend components without a concrete UX need.
878+Enrich the now-working application with information and workflows that V2 models explicitly but the V1 UI could not represent well. The production chat must move from the temporary Shape 4.0 parity reconstruction to the server-authoritative ordered V2 item sequence without introducing a second complete projection or a shared React presentation layer.
863879
864−### 1. Build the complete shared V2 presentation model
880+The V2 projection remains the client semantic source of truth. Client enrichment adds environment scope and narrowly derived relationships; it does not copy every projection table into another thread-shaped model.
865881
866−Extend the shared derivation layer to use:
882+### Shape 4.5 implementation order
867883
868−- `visibleTurnItems`
869−- runs and attempts
870−- execution nodes
871−- provider sessions, threads, and turns
872−- runtime requests
873−- plans and todo steps
874−- checkpoint scopes and captures
875−- context handoffs and transfers
876−- fork, merge-back, and subagent relationships
877−
878−Expose stable UI entries for:
879−
880−- user and assistant messages
881−- reasoning
882−- typed tool and command execution
883−- file changes
884−- file, code, and web search
885−- approvals and user-input requests
886−- plans and todo lists
887−- checkpoints and rollback activity
888−- interrupts, retries, and recovered attempts
889−- handoffs, forks, merges, and subagents
890−- errors and diagnostic status
891−
892−Preserve provider-native and dynamic tool payloads behind a structured fallback so adding a new tool type never makes information disappear.
893−
894−### 2. Expose thread and execution relationships
895−
896−Create a shared graph/lineage model from thread lineage, execution nodes, subagents, forks, and context transfers. It must support:
884+Complete the following sections in order. The client-runtime substrate is intentionally small; web proves the first production presentation, mobile follows from the same projection semantics, and only then are demonstrated shared domain selectors extracted.
897885
898−- root, parent, child, fork, and subagent relationships
899−- navigation between related threads
900−- per-node run and terminal status
901−- merge-back and context-transfer state
902−- archived or deleted related threads
903−- missing-parent, partial-replay, and cycle-safe fallback behavior
886+### 1. Establish the scoped projection and item atom boundary
887+
888+Keep shell and detail as independent sources:
889+
890+- `EnvironmentThreadShell` remains the environment-scoped, list-oriented view of the server shell stream.
891+- Introduce a detail value containing `environmentId` and the pristine `OrchestrationV2ThreadProjection`. Use a temporary migration name such as `ScopedThreadProjection` while the Shape 4.0 parity facade still owns `EnvironmentThread`; after that facade is removed, the contextual wrapper may take the unversioned `EnvironmentThread` name.
892+- Preserve `wrapper.projection === projectionState.data` and the identities of every nested projection collection. Do not spread or translate the complete projection into a second object graph.
893+- Do not attach `environmentId` to every nested message, run, node, or item. Values that leave their scoped thread context or participate in cross-thread relationships must carry an explicit `ScopedThreadRef` or equivalent environment-qualified reference.
894+- Shell consumers read shell atoms. Detail consumers read `projection.thread` and detail atoms. Do not synthesize a shell from detail, globally merge the streams, or make sidebar state depend on detail traffic. A component that truly needs both subscribes to both explicitly.
895+
896+The stable client-runtime access surface begins with:
897+
898+- the full thread state atom, including cached, synchronizing, live, deleted, and error state
899+- a scoped whole-thread atom for debugger, graph, and other whole-projection consumers
900+- a `visibleTurnItems` atom exposing the server-projected ordered item rows
901+- status/error or an equivalently stable synchronization atom that does not invalidate merely because projection content changed
902+
903+`visibleTurnItems` is the production conversation source. It already carries the discriminated union for messages, reasoning, plans, tools, approvals, input requests, checkpoints, interruption request/result items, compaction, handoff, fork, subagent, and dynamic tools. It also carries local, inherited, and synthetic visibility metadata. Production chat must not reconstruct this sequence by merging separate messages, plans, and work-entry arrays.
904+
905+Do not publish one atom merely because each projection table exists. Add a derived atom or an entity-by-ID lookup only when a concrete row, control, graph, or inspector needs state absent from its turn item, for example:
906+
907+- runtime-request response capability for an approval or user-input row
908+- current checkpoint/ref state for rollback
909+- run, attempt, or node state for execution inspection
910+- context-transfer lifecycle for merge-back or provider handoff
911+
912+These lookups must retain the original V2 entity types and identities. A whole-thread atom is a firehose and must not be the default subscription for granular timeline components.
913+
914+Keep web and mobile React bindings separate. Do not introduce a shared React package, a shared binding factory, or a `createClientStateGraph` abstraction merely because the two bindings are currently similar.
915+
916+#### `visibleTurnItems` structural-sharing prerequisite
917+
918+Fix the incremental client reducer before the production rich timeline depends on it:
904919
905−Web should provide an efficient tree or graph surface without replacing the ordinary project/thread sidebar. Mobile should provide the same relationships through a form-factor-appropriate drill-down rather than attempting to copy the desktop visualization exactly.
920+- inherited and synthetic rows remain server-authoritative and are never locally reconstructed
921+- a run or attempt update that does not change visible membership returns the previous `visibleTurnItems` array reference
922+- a turn-item update with unchanged membership replaces only the affected row and preserves every unaffected row object and position
923+- positions are recomputed only when visible membership changes
924+- membership equality alone must not suppress a streaming item-content update
906925
907−### 3. Enrich tool and work-log presentation
926+This is a reducer allocation and correctness requirement, not an atom-level equality workaround.
908927
909−Reuse the existing chat activity and work-log components, then add V2-aware renderers for:
928+### 2. Move the web conversation to authoritative V2 items
910929
911−- command execution with command, working directory, lifecycle, output, duration, and exit state
930+Implement the first Shape 4.5 production vertical slice in web:
931+
932+- render the conversation directly from `visibleTurnItems`
933+- preserve item order across interruption request, intervening provider activity, and interruption result
934+- preserve inherited items and synthetic fork markers
935+- render user and assistant messages, reasoning, plans, tool activity, requests, checkpoints, compaction, handoffs, forks, subagents, and dynamic tools from the discriminated union
936+- retain complete structured/provider-native input and output behind a safe fallback for every item type
937+- join a row to targeted supporting state by ID only when the item itself is insufficient for interaction or inspection
938+
939+Reuse the proven presentation work in the V2 debugger where it fits, especially its direct `visibleTurnItems` path and item renderers. Do not reuse the debugger's local subscriptions, local projection reducer, log reconstruction fallback, or debug-only state management. Production consumes the client-runtime thread state established in section 1.
940+
941+Web owns visual row types, icons, labels, folding, adjacent visual grouping, expansion, and copy behavior. Those policies are not client-runtime domain models.
942+
943+### 3. Move the mobile conversation to the same projection semantics
944+
945+After the web item path is working, move mobile from its Shape 4.0 parity feed to the same authoritative `visibleTurnItems` sequence and targeted entity lookups.
946+
947+Mobile keeps its own React bindings and form-factor-specific presentation, including native list behavior, expansion, composer layout, offline/outbox policy, and navigation. It does not need to share web row interfaces, fold policy, labels, or visual grouping.
948+
949+Promote logic into client-runtime only when both platforms need the same nontrivial domain interpretation and the relationship is not already explicit in V2. Examples may include environment-scoped cross-thread references or capability-safe entity linkage. Do not extract presentation logic merely because two current implementations happen to look alike.
950+
951+### 4. Enrich tools, execution, and supporting state
952+
953+Add item renderers and targeted inspectors for:
954+
955+- command execution with input, working directory where available, lifecycle, output, duration, and exit state
912956 - file changes with affected paths and patch/diff navigation
913957 - file, code, and web search with query and result provenance
914−- MCP and provider-native tool calls
915−- dynamic tools with complete structured input/output fallback
958+- MCP, dynamic, and provider-native tool calls with complete structured fallback
916959 - nested tool work associated with execution nodes or subagents
917−- failed, interrupted, retried, and superseded calls
960+- queued, steering, restarting, active, interrupted, failed, retried, superseded, and recovered execution
961+- attempt history, execution-node progress, and retry reasons
962+- provider session/thread/turn identity where diagnostically useful
963+- native resume versus portable context handoff
964+- context-window and token indicators
965+- plans, todos, approvals, user input, checkpoints, and rollback state
918966
919−Rendering must preserve live updates without duplicating rows as attempts or provider items change. Large output should remain inspectable without overwhelming the conversation timeline.
967+The ordered chat remains item-based. Runs, attempts, nodes, provider state, runtime requests, checkpoints, and transfers are supporting relational state; they enrich specific rows, controls, and inspectors rather than becoming additional reconstructed chat feeds.
920968
921−### 4. Expose runs, attempts, and recovery
969+Keep primary chat status simple. Detailed execution state belongs in targeted or expandable surfaces. Rendering must preserve live updates without duplicating rows as attempts or provider items change, and large output must remain inspectable without overwhelming the timeline.
922970
923−Add UI affordances for:
971+### 5. Expose relationships and V2-native workflows
924972
925−- queued, steering, restarting, and active runs
926−- attempt history and retry reasons
927−- execution-node progress
928−- provider session/thread identity where diagnostically useful
929−- native resume versus portable context handoff
930−- context-window and token indicators
931−- recoverable versus terminal failure state
973+Build environment-scoped, cycle-safe graph/lineage derivation from shell threads, thread lineage, execution nodes, subagents, forks, and context transfers. It must support:
932974
933−Keep the primary chat status simple. Detailed runtime state belongs in expandable inspection surfaces rather than permanently crowding the composer or message list.
975+- root, parent, child, fork, and subagent relationships
976+- navigation between related threads using environment-qualified references
977+- per-node run and terminal status
978+- merge-back and context-transfer state
979+- archived or deleted related threads
980+- missing-parent and partial-replay fallback
934981
935−### 5. Add V2-native workflows
982+Web should provide an efficient tree or graph surface without replacing the ordinary project/thread sidebar. Mobile should expose the same domain relationships through a form-factor-appropriate drill-down rather than copying the desktop visualization.
936983
937−Integrate:
984+Integrate capability-gated workflows for:
938985
939986 - thread fork and merge-back
940987 - provider switching and provider-session detach
941988 - advanced queue reorder and promotion
942989 - subagent navigation and delegated-task state
943990 - plan/todo progress
944−- checkpoint scopes, diff, and rollback
991+- checkpoint scope, diff, and rollback
945992 - context handoff and transfer visibility
946993
947−Each action must be capability-gated from V2 projection/runtime policy rather than provider-name checks in the UI.
994+Actions derive from V2 capability and runtime policy. They must not use provider-name conditionals.
995+
996+### 6. Retire parity state and finish cleanup
997+
998+After web and mobile no longer depend on the Shape 4.0 full-thread parity facade:
948999
949−### 6. Finish web/mobile reuse and cleanup
1000+- remove `presentThread`-style whole-projection remapping and the shell/detail reconciliation it required
1001+- give the environment-scoped projection wrapper the final unversioned client name if doing so improves clarity
1002+- remove message/work/plan feed reconstruction and other obsolete transformations from `session-logic.ts`, mobile `threadActivity.ts`, and equivalent modules while retaining genuinely platform-specific presentation helpers
1003+- remove unused V1 client RPC contracts and compatibility exports
1004+- split still-required project application contracts and frozen legacy thread decoders into clear ownership boundaries instead of deleting reusable application infrastructure
1005+- delete the V2 debug route after its useful inspection and rendering surfaces have production homes
9501006
951−- Use one shared presentation derivation for web and mobile.
952−- Keep platform-specific visual components where appropriate, but do not duplicate event interpretation.
953−- Remove obsolete V1 `session-logic.ts`, mobile `threadActivity.ts`, and equivalent transformations after their consumers are gone.
954−- Remove unused V1 client RPC contracts and compatibility exports.
955−- Split still-required project application contracts and frozen legacy thread decoders into clear ownership boundaries instead of deleting reusable application infrastructure.
956−- Delete the V2 debug route after its inspection tools and useful renderers have production homes.
957−- Rename V2 production-facing client types to unversioned names only if the rename reduces long-term ambiguity without obscuring the retained application event-source contracts.
1007+No production client should end with both a complete `OrchestrationV2ThreadProjection` and a second complete thread presentation object containing copied messages, work entries, runs, plans, requests, and checkpoints.
9581008
9591009 ### Shape 4.5 tests
9601010
961−Add shared presentation tests for:
1011+Add client-runtime state and reducer coverage for:
9621012
963−- every known V2 turn-item type and the dynamic fallback
964−- graph construction for roots, forks, subagents, merges, archives, and missing nodes
965−- incremental updates without duplicate graph nodes or timeline rows
966−- run attempts, retry, recovery, and interruption presentation
967−- provider-session transition and context-handoff presentation
968−- plans, todos, checkpoints, and rollback
1013+- environment-scoped detail identity without copying or mutating the server projection
1014+- cached, synchronizing, live, deleted, and error state independent of projection content
1015+- preservation of local, inherited, and synthetic `visibleTurnItems`
1016+- unchanged `visibleTurnItems` identity for run/attempt updates that do not change visibility
1017+- single-row replacement and unaffected-row identity for streaming item updates
1018+- membership changes, removal, and position renumbering without losing inherited or synthetic rows
1019+- any targeted entity lookup or shared domain selector introduced by a concrete consumer
1020+- environment-scoped graph construction for roots, forks, subagents, merges, archives, missing nodes, and cycles
9691021 - capability-gated action availability
9701022
9711023 Add web and mobile integration coverage for:
9721024
1025+- every known V2 turn-item type and the structured dynamic fallback
1026+- interruption request, intervening activity, and interruption result in committed order
1027+- inherited conversation items and synthetic fork markers
1028+- rich tool expansion and live updates without duplicate rows
1029+- request response capability, checkpoint rollback, and other targeted item/entity joins
9731030 - related-thread navigation
974−- rich tool-call expansion and live updates
9751031 - fork and merge-back
9761032 - subagent and delegated-task inspection
9771033 - queue reorder and promotion
9781034 - provider switch and detach
9791035 - context handoff
980−- reconnect while graph, tool, or attempt state is changing
1036+- reconnect while item, graph, tool, request, or attempt state is changing
1037+- all existing Shape 4.0 product flows
9811038
9821039 ### Shape 4.5 exit gate
9831040
9841041 - Web and mobile remain fully usable for every Shape 4.0 flow.
985−- Thread lineage, forks, subagents, and merge relationships are navigable.
986−- Typed tool calls and their structured results are visible without losing provider-native fallback data.
987−- Runs, attempts, retries, recovery, plans, checkpoints, and handoffs have coherent presentation.
1042+- Production web and mobile conversations render from server-authoritative `visibleTurnItems`; neither reconstructs the chat by merging separate message, plan, and work-entry collections.
1043+- Thread detail values carry explicit environment scope while retaining the pristine V2 projection and nested entity identities.
1044+- Shell/sidebar and thread-detail state remain independently subscribable; ordinary detail traffic does not invalidate the project/thread shell.
1045+- Granular timeline components consume the item atom and targeted entity lookups rather than subscribing to the whole projection.
1046+- Run/attempt updates with unchanged visibility preserve `visibleTurnItems` identity, while streaming item updates replace only the affected row and remain visible.
1047+- Local, inherited, and synthetic items survive snapshot, incremental update, cache restore, and reconnect.
1048+- Typed tool calls and structured results are visible without losing provider-native fallback data.
1049+- Interruption request, intervening activity, and interruption result remain independently visible and correctly ordered.
1050+- Runs, attempts, retries, recovery, plans, checkpoints, requests, handoffs, and context transfers have coherent targeted presentation without becoming a second reconstructed chat model.
1051+- Thread lineage, forks, subagents, merge relationships, and environment-qualified navigation are available on web and mobile in platform-appropriate forms.
9881052 - New V2 capabilities are capability-gated and do not rely on provider-name conditionals.
989−- Web and mobile share event interpretation and presentation derivation.
1053+- Web and mobile retain separate React bindings and presentation policy while consuming the same V2 projection/reducer semantics.
1054+- No production client maintains a complete parallel thread presentation model beside `OrchestrationV2ThreadProjection`; the Shape 4.0 parity facade is removed from production consumers.
9901055 - No production TypeScript import references a V1 client orchestration type or endpoint.
1056+- The V2 debug route is removed after its useful production surfaces are integrated.
9911057 - `vp check`, `vp run typecheck`, and `vp test` pass.
9921058 - `vp run lint:mobile` passes for mobile changes.
9931059
@@ -1011,7 +1077,8 @@ Nothing in Shape 4.0 or 4.5 may destroy, rewrite, or make assumptions that close · 17 unmodified lines
10111077 At the end of Shape 4.5:
10121078
10131079 - V2 is the only live agent orchestration system from provider process through web and mobile UI.
1014−- The existing product experience works on V2, and V2-native graph, execution, tool, and context information is available in the application.
1080+- The existing product experience works on V2, and production conversations render the authoritative environment-scoped V2 item sequence without a parallel full-thread presentation model.
1081+- V2-native graph, execution, tool, request, interruption, and context information is available through targeted platform-appropriate surfaces.
10151082 - V1 runtime, server endpoints, production client state, and production client operations are removed.
10161083 - Reusable application event-sourcing and platform infrastructure remain.
10171084 - Legacy database rows, provider continuation data, attachments, and other migration inputs remain untouched and read-only.
apps/mobile/src/connection/storage.ts+249−1
@@ -2,6 +2,11 @@ import { · 1 unmodified line
22 ConnectionPersistenceError,
33 ConnectionRegistrationStore,
44 ConnectionTargetStore,
5+ EnvironmentCacheStore,
6+ ORCHESTRATION_CACHE_SCHEMA_VERSION,
7+ StoredOrchestrationShellSnapshot,
8+ StoredOrchestrationThreadSnapshot,
9+ decodeOrDiscardOrchestrationCache,
510 registerConnectionInCatalog,
611 removeConnectionFromCatalog,
712 removeCatalogValue,
@@ -13,11 +18,88 @@ import { · 5 unmodified lines
1318 CredentialStore,
1419 ProfileStore,
1520 } from "@t3tools/client-runtime/connection";
21+import { EnvironmentId, ThreadId } from "@t3tools/contracts";
1622 import * as Context from "effect/Context";
1723 import * as Effect from "effect/Effect";
1824 import * as Layer from "effect/Layer";
1925 import * as Option from "effect/Option";
20−import * as CatalogStore from "./catalog-store";
26+import * as Schema from "effect/Schema";
27+import * as SecureStore from "expo-secure-store";
28+
29+import { makeCatalogStore, type SecureCatalogStorage } from "./catalog-store";
30+
31+const SHELL_SNAPSHOT_CACHE_DIRECTORY = "connection-shell-snapshots";
32+const LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots";
33+const THREAD_SNAPSHOT_CACHE_DIRECTORY = "connection-thread-snapshots";
34+
35+const StoredShellSnapshot = StoredOrchestrationShellSnapshot;
36+const StoredThreadSnapshot = StoredOrchestrationThreadSnapshot;
37+const decodeStoredShellSnapshot = Schema.decodeUnknownResult(StoredShellSnapshot);
38+const encodeStoredShellSnapshot = Schema.encodeUnknownResult(StoredShellSnapshot);
39+const decodeStoredThreadSnapshot = Schema.decodeUnknownResult(StoredThreadSnapshot);
40+const encodeStoredThreadSnapshot = Schema.encodeUnknownResult(StoredThreadSnapshot);
41+
42+function catalogError(operation: string, cause: unknown) {
43+ return new ConnectionTransientError({
44+ reason: "remote-unavailable",
45+ detail: `Could not ${operation} the local connection catalog: ${String(cause)}`,
46+ });
47+}
48+
49+function shellPersistenceError(
50+ operation:
51+ | "load-shell"
52+ | "save-shell"
53+ | "load-thread"
54+ | "save-thread"
55+ | "remove-thread"
56+ | "clear-environment",
57+ cause: unknown,
58+) {
59+ return new ConnectionPersistenceError({
60+ operation,
61+ message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`,
62+ });
63+}
64+
65+function threadSnapshotFileName(threadId: ThreadId): string {
66+ return `${encodeURIComponent(threadId)}.json`;
67+}
68+
69+const threadSnapshotDirectory = Effect.fn("mobile.connectionStorage.threadSnapshotDirectory")(
70+ function* (
71+ environmentId: EnvironmentId,
72+ operation: "load-thread" | "save-thread" | "remove-thread" | "clear-environment",
73+ ) {
74+ return yield* Effect.tryPromise({
75+ try: async () => {
76+ const { Directory, Paths } = await import("expo-file-system");
77+ const directory = new Directory(
78+ Paths.document,
79+ THREAD_SNAPSHOT_CACHE_DIRECTORY,
80+ encodeURIComponent(environmentId),
81+ );
82+ if (operation !== "clear-environment") {
83+ directory.create({ idempotent: true, intermediates: true });
84+ }
85+ return directory;
86+ },
87+ catch: (cause) => shellPersistenceError(operation, cause),
88+ });
89+ },
90+);
91+
92+const threadSnapshotFile = Effect.fn("mobile.connectionStorage.threadSnapshotFile")(function* (
93+ environmentId: EnvironmentId,
94+ threadId: ThreadId,
95+ operation: "load-thread" | "save-thread" | "remove-thread",
96+) {
97+ const { File } = yield* Effect.promise(() => import("expo-file-system"));
98+ return new File(
99+ yield* threadSnapshotDirectory(environmentId, operation),
100+ threadSnapshotFileName(threadId),
101+ );
102+});
21103
22104 function targetPersistenceError(
23105 operation: "list-targets" | "register-connection" | "remove-connection",
@@ -128,6 +210,172 @@ export const connectionStorageLayer = Layer.effectContext( · 104 unmodified lines
128210 ),
129211 })),
130212 });
213+ const cacheStore = EnvironmentCacheStore.of({
214+ loadShell: (environmentId) =>
215+ Effect.gen(function* () {
216+ const file = yield* shellSnapshotFile(environmentId, "load-shell");
217+ if (file.exists) {
218+ const raw = yield* Effect.tryPromise({
219+ try: () => file.text(),
220+ catch: (cause) => shellPersistenceError("load-shell", cause),
221+ });
222+ const parsed = yield* Effect.try({
223+ try: () => JSON.parse(raw) as unknown,
224+ catch: (cause) => shellPersistenceError("load-shell", cause),
225+ });
226+ const stored = yield* Effect.fromResult(decodeStoredShellSnapshot(parsed)).pipe(
227+ Effect.mapError((cause) => shellPersistenceError("load-shell", cause)),
228+ );
229+ return stored.environmentId === environmentId
230+ ? Option.some(stored.snapshot)
231+ : Option.none();
232+ }
233+
234+ const legacyFile = yield* legacyShellSnapshotFile(environmentId, "load-shell");
235+ if (legacyFile.exists) {
236+ yield* Effect.try({
237+ try: () => legacyFile.delete(),
238+ catch: (cause) => shellPersistenceError("load-shell", cause),
239+ });
240+ }
241+
242+ return Option.none();
243+ }).pipe((decode) =>
244+ decodeOrDiscardOrchestrationCache(
245+ decode,
246+ shellSnapshotFile(environmentId, "load-shell").pipe(
247+ Effect.flatMap((file) =>
248+ Effect.try({
249+ try: () => {
250+ if (file.exists) file.delete();
251+ },
252+ catch: (cause) => shellPersistenceError("load-shell", cause),
253+ }),
254+ ),
255+ Effect.catch(() => Effect.void),
256+ ),
257+ ),
258+ ),
259+ saveShell: (environmentId, snapshot) =>
260+ Effect.gen(function* () {
261+ const file = yield* shellSnapshotFile(environmentId, "save-shell");
262+ const stored = {
263+ schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
264+ environmentId,
265+ snapshot,
266+ } as const;
267+ const encoded = yield* Effect.fromResult(encodeStoredShellSnapshot(stored)).pipe(
268+ Effect.mapError((cause) => shellPersistenceError("save-shell", cause)),
269+ );
270+ yield* Effect.try({
271+ try: () => {
272+ if (!file.exists) {
273+ file.create({ intermediates: true, overwrite: true });
274+ }
275+ file.write(JSON.stringify(encoded));
276+ },
277+ catch: (cause) => shellPersistenceError("save-shell", cause),
278+ });
279+ }),
280+ loadThread: (environmentId, threadId) =>
281+ Effect.gen(function* () {
282+ const file = yield* threadSnapshotFile(environmentId, threadId, "load-thread");
283+ if (!file.exists) {
284+ return Option.none();
285+ }
286+ const raw = yield* Effect.tryPromise({
287+ try: () => file.text(),
288+ catch: (cause) => shellPersistenceError("load-thread", cause),
289+ });
290+ const parsed = yield* Effect.try({
291+ try: () => JSON.parse(raw) as unknown,
292+ catch: (cause) => shellPersistenceError("load-thread", cause),
293+ });
294+ const stored = yield* Effect.fromResult(decodeStoredThreadSnapshot(parsed)).pipe(
295+ Effect.mapError((cause) => shellPersistenceError("load-thread", cause)),
296+ );
297+ return stored.environmentId === environmentId && stored.threadId === threadId
298+ ? Option.some(stored.thread)
299+ : Option.none();
300+ }).pipe((decode) =>
301+ decodeOrDiscardOrchestrationCache(
302+ decode,
303+ threadSnapshotFile(environmentId, threadId, "load-thread").pipe(
304+ Effect.flatMap((file) =>
305+ Effect.try({
306+ try: () => {
307+ if (file.exists) file.delete();
308+ },
309+ catch: (cause) => shellPersistenceError("load-thread", cause),
310+ }),
311+ ),
312+ Effect.catch(() => Effect.void),
313+ ),
314+ ),
315+ ),
316+ saveThread: (environmentId, thread) =>
317+ Effect.gen(function* () {
318+ const file = yield* threadSnapshotFile(environmentId, thread.thread.id, "save-thread");
319+ const encoded = yield* Effect.fromResult(
320+ encodeStoredThreadSnapshot({
321+ schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
322+ environmentId,
323+ threadId: thread.thread.id,
324+ thread,
325+ }),
326+ ).pipe(Effect.mapError((cause) => shellPersistenceError("save-thread", cause)));
327+ yield* Effect.try({
328+ try: () => {
329+ if (!file.exists) {
330+ file.create({ intermediates: true, overwrite: true });
331+ }
332+ file.write(JSON.stringify(encoded));
333+ },
334+ catch: (cause) => shellPersistenceError("save-thread", cause),
335+ });
336+ }),
337+ removeThread: (environmentId, threadId) =>
338+ Effect.gen(function* () {
339+ const file = yield* threadSnapshotFile(environmentId, threadId, "remove-thread");
340+ if (file.exists) {
341+ file.delete();
342+ }
343+ }).pipe(
344+ Effect.mapError((cause) =>
345+ cause._tag === "ConnectionPersistenceError"
346+ ? cause
347+ : shellPersistenceError("remove-thread", cause),
348+ ),
349+ ),
350+ clear: (environmentId) =>
351+ Effect.gen(function* () {
352+ const file = yield* shellSnapshotFile(environmentId, "clear-environment");
353+ if (file.exists) {
354+ yield* Effect.try({
355+ try: () => file.delete(),
356+ catch: (cause) => shellPersistenceError("clear-environment", cause),
357+ });
358+ }
359+ const legacyFile = yield* legacyShellSnapshotFile(environmentId, "clear-environment");
360+ if (legacyFile.exists) {
361+ yield* Effect.try({
362+ try: () => legacyFile.delete(),
363+ catch: (cause) => shellPersistenceError("clear-environment", cause),
364+ });
365+ }
366+ const threadDirectory = yield* threadSnapshotDirectory(
367+ environmentId,
368+ "clear-environment",
369+ );
370+ if (threadDirectory.exists) {
371+ yield* Effect.try({
372+ try: () => threadDirectory.delete(),
373+ catch: (cause) => shellPersistenceError("clear-environment", cause),
374+ });
375+ }
376+ }),
377+ });
378+
131379 return Context.make(ConnectionTargetStore, targetStore).pipe(
132380 Context.add(ConnectionRegistrationStore, registrationStore),
133381 Context.add(ProfileStore.ConnectionProfileStore, profileStore),
apps/mobile/src/features/archive/archivedThreadList.test.ts+15−25
@@ -1,9 +1,11 @@
11 import type { ArchivedSnapshotEntry } from "@t3tools/client-runtime/state/threads";
2−import type { OrchestrationProjectShell, OrchestrationThreadShell } from "@t3tools/contracts";
3−import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
2+import type { OrchestrationProjectShell, OrchestrationV2ThreadShell } from "@t3tools/contracts";
3+import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
44 import { describe, expect, it } from "vite-plus/test";
5+import * as DateTime from "effect/DateTime";
56
67 import { buildArchivedThreadGroups } from "./archivedThreadList";
8+import { makeRawThreadShell } from "../../test-fixtures";
79
810 const environmentId = EnvironmentId.make("environment-1");
911
@@ -22,42 +24,30 @@ function makeProject( · 12 unmodified lines
2224 }
2325
2426 function makeThread(
25− input: Partial<OrchestrationThreadShell> &
26− Pick<OrchestrationThreadShell, "id" | "projectId" | "title">,
27−): OrchestrationThreadShell {
28− return {
29− modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
30− runtimeMode: "full-access",
31− interactionMode: "default",
32− branch: null,
33− worktreePath: null,
34− latestTurn: null,
35− createdAt: "2026-06-01T00:00:00.000Z",
36− updatedAt: "2026-06-01T00:00:00.000Z",
37− archivedAt: "2026-06-02T00:00:00.000Z",
38− session: null,
39− latestUserMessageAt: null,
40− hasPendingApprovals: false,
41− hasPendingUserInput: false,
42− hasActionableProposedPlan: false,
27+ input: Pick<OrchestrationV2ThreadShell, "id" | "projectId" | "title"> & {
28+ readonly branch?: string | null;
29+ readonly archivedAt?: string | null;
30+ },
31+): OrchestrationV2ThreadShell {
32+ const archivedAt = input.archivedAt === undefined ? "2026-06-02T00:00:00.000Z" : input.archivedAt;
33+ return makeRawThreadShell({
4334 ...input,
44− settledOverride: input.settledOverride ?? null,
45− settledAt: input.settledAt ?? null,
46− };
35+ archivedAt: archivedAt === null ? null : DateTime.makeUnsafe(archivedAt),
36+ });
4737 }
4838
4939 function makeSnapshot(
5040 projects: ReadonlyArray<OrchestrationProjectShell>,
51− threads: ReadonlyArray<OrchestrationThreadShell>,
41+ threads: ReadonlyArray<OrchestrationV2ThreadShell>,
5242 targetEnvironmentId = environmentId,
5343 ): ArchivedSnapshotEntry {
5444 return {
5545 environmentId: targetEnvironmentId,
5646 snapshot: {
47+ schemaVersion: 1,
5748 snapshotSequence: 1,
5849 projects,
5950 threads,
60− updatedAt: "2026-06-04T00:00:00.000Z",
6151 },
6252 };
6353 }
apps/mobile/src/features/home/HomeScreen.tsx+57−28
@@ -118,14 +118,51 @@ interface HomeScreenProps { · 117 unmodified lines
118118
119119 /* ─── Layout constants ───────────────────────────────────────────────── */
120120
121−const ESTIMATED_THREAD_ROW_HEIGHT = 72;
122−const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44;
123−/**
124− * Top spacing between the list and the Android custom header. The Android
125− * header (AndroidHomeHeader) is rendered in-flow above this screen and
126− * already consumes the top safe-area inset, so the list only needs breathing
127− * room here.
128− */
121+function statusColors(thread: EnvironmentThreadShell): { bg: string; fg: string } {
122+ switch (thread.runtime?.status) {
123+ case "running":
124+ case "waiting":
125+ return { bg: "rgba(249,115,22,0.14)", fg: "#f97316" };
126+ case "completed":
127+ return { bg: "rgba(34,197,94,0.14)", fg: "#22c55e" };
128+ case "queued":
129+ case "starting":
130+ return { bg: "rgba(59,130,246,0.14)", fg: "#3b82f6" };
131+ case "failed":
132+ return { bg: "rgba(239,68,68,0.14)", fg: "#ef4444" };
133+ default:
134+ return { bg: "rgba(163,163,163,0.10)", fg: "#a3a3a3" };
135+ }
136+}
137+
138+const COLLAPSED_THREAD_LIMIT = 6;
139+const THREAD_LAYOUT_TRANSITION = LinearTransition.duration(220).easing(Easing.out(Easing.cubic));
140+
141+function threadRowExit(values: ExitAnimationsValues) {
142+ "worklet";
143+
144+ return {
145+ initialValues: {
146+ height: values.currentHeight,
147+ opacity: 1,
148+ originX: values.currentOriginX,
149+ },
150+ animations: {
151+ height: withDelay(
152+ 90,
153+ withTiming(0, {
154+ duration: 170,
155+ easing: Easing.inOut(Easing.cubic),
156+ }),
157+ ),
158+ opacity: withDelay(80, withTiming(0, { duration: 100 })),
159+ originX: withTiming(values.currentOriginX - values.windowWidth, {
160+ duration: 190,
161+ easing: Easing.out(Easing.cubic),
162+ }),
163+ },
164+ };
165+}
129166
130167 function deriveEmptyState(props: {
131168 readonly catalogState: WorkspaceState;
@@ -185,7 +222,7 @@ function deriveEmptyState(props: { · 53 unmodified lines
185222
186223 return {
187224 title: "No threads yet",
188− detail: "Create a task to start a new coding session in one of your connected projects.",
225+ detail: "Create a task to start a new coding runtime in one of your connected projects.",
189226 loading: false,
190227 };
191228 }
@@ -971,25 +1008,17 @@ export function HomeScreen(props: HomeScreenProps) { · 779 unmodified lines
9711008 onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined}
9721009 variant="plain"
9731010 />
974− {emptyState.loading && !shouldShowConnectionStatus ? (
975− <View className="mt-4 items-center">
976− <ActivityIndicator color={accentColor} />
977− </View>
978− ) : null}
979− {shouldShowConnectionStatus && Platform.OS === "ios" ? (
980− <View className="mt-4">
981− <WorkspaceConnectionStatus
982− state={props.catalogState}
983− onPress={props.onOpenEnvironments}
984− variant="sidebar"
985− />
986− </View>
987− ) : null}
988− </View>
989− {connectionStatus}
990− </View>
991− );
992− }
1011+ ) : !hasResults ? (
1012+ <EmptyState
1013+ title="No threads yet"
1014+ detail="Create a task to start a new coding runtime."
1015+ />
1016+ ) : (
1017+ projectGroups.map((group) => {
1018+ const isExpanded = expandedProjects.has(group.key);
1019+ const visibleThreads = isExpanded
1020+ ? group.threads
1021+ : group.threads.slice(0, COLLAPSED_THREAD_LIMIT);
9931022
9941023 const listHeader = (
9951024 <>
apps/mobile/src/features/home/homeThreadList.test.ts+4−11
@@ -6,11 +6,8 @@ import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-searc · 5 unmodified lines
66 import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
77 import { describe, expect, it } from "vite-plus/test";
88
9−import {
10− buildHomeProjectScopes,
11− buildHomeThreadGroups,
12− sortHomeProjectScopes,
13−} from "./homeThreadList";
9+import { buildHomeThreadGroups } from "./homeThreadList";
10+import { makeThreadShellFixture } from "../../test-fixtures";
1411
1512 function makeProject(
1613 input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">,
@@ -30,25 +27,21 @@ function makeThread( · 13 unmodified lines
3027 input: Partial<EnvironmentThreadShell> &
3128 Pick<EnvironmentThreadShell, "environmentId" | "id" | "projectId" | "title">,
3229 ): EnvironmentThreadShell {
33− return {
30+ return makeThreadShellFixture({
3431 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
3532 runtimeMode: "full-access",
3633 interactionMode: "default",
3734 branch: null,
3835 worktreePath: null,
39− latestTurn: null,
4036 createdAt: "2026-06-01T00:00:00.000Z",
4137 updatedAt: "2026-06-01T00:00:00.000Z",
4238 archivedAt: null,
43− session: null,
4439 latestUserMessageAt: null,
4540 hasPendingApprovals: false,
4641 hasPendingUserInput: false,
4742 hasActionableProposedPlan: false,
4843 ...input,
49− settledOverride: input.settledOverride ?? null,
50− settledAt: input.settledAt ?? null,
51− };
44+ });
5245 }
5346
5447 const NOW = Date.parse("2026-06-29T00:00:00.000Z");
apps/mobile/src/features/review/reviewModel.test.ts+7−11
@@ -1,11 +1,7 @@
11 import { describe, expect, it } from "vite-plus/test";
22
3−import {
4− MessageId,
5− TurnId,
6− type OrchestrationCheckpointSummary,
7− type ReviewDiffPreviewSource,
8−} from "@t3tools/contracts";
3+import { MessageId, RunId, type ReviewDiffPreviewSource } from "@t3tools/contracts";
4+import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/shell";
95
106 import {
117 buildReviewListItems,
@@ -18,9 +14,9 @@ import { · 6 unmodified lines
1814 } from "./reviewModel";
1915
2016 function makeCheckpoint(
21− input: Partial<OrchestrationCheckpointSummary> &
22− Pick<OrchestrationCheckpointSummary, "turnId" | "checkpointTurnCount" | "completedAt">,
23−): OrchestrationCheckpointSummary {
17+ input: Partial<ThreadCheckpointSummary> &
18+ Pick<ThreadCheckpointSummary, "runId" | "checkpointTurnCount" | "completedAt">,
19+): ThreadCheckpointSummary {
2420 return {
2521 checkpointRef: `refs/t3/checkpoints/thread/${input.checkpointTurnCount}` as any,
2622 status: "ready",
@@ -52,12 +48,12 @@ describe("buildReviewSectionItems", () => { · 25 unmodified lines
5248 it("keeps one chip per checkpoint and appends git sources", () => {
5349 const checkpoints = [
5450 makeCheckpoint({
55− turnId: TurnId.make("turn-1"),
51+ runId: RunId.make("run-1"),
5652 checkpointTurnCount: 1,
5753 completedAt: "2026-04-01T00:00:00.000Z",
5854 }),
5955 makeCheckpoint({
60− turnId: TurnId.make("turn-2"),
56+ runId: RunId.make("run-2"),
6157 checkpointTurnCount: 2,
6258 completedAt: "2026-04-02T00:00:00.000Z",
6359 }),
apps/mobile/src/features/review/reviewModel.ts+11−10
@@ -1,6 +1,7 @@
11 import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
22 import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types";
3−import type { OrchestrationCheckpointSummary, ReviewDiffPreviewSource } from "@t3tools/contracts";
3+import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/shell";
4+import type { ReviewDiffPreviewSource } from "@t3tools/contracts";
45 import * as Arr from "effect/Array";
56 import { pipe } from "effect/Function";
67 import * as Order from "effect/Order";
@@ -128,11 +129,11 @@ export type ReviewParsedDiff = · 121 unmodified lines
128129 readonly notice: string | null;
129130 };
130131
131−function checkpointTitle(checkpoint: OrchestrationCheckpointSummary): string {
132+function checkpointTitle(checkpoint: ThreadCheckpointSummary): string {
132133 return `Turn ${checkpoint.checkpointTurnCount}`;
133134 }
134135
135−function checkpointSubtitle(checkpoint: OrchestrationCheckpointSummary): string {
136+function checkpointSubtitle(checkpoint: ThreadCheckpointSummary): string {
136137 const fileCount = checkpoint.files.length;
137138 if (checkpoint.status !== "ready") {
138139 return `Diff ${checkpoint.status}`;
@@ -141,8 +142,8 @@ function checkpointSubtitle(checkpoint: OrchestrationCheckpointSummary): string · 2 unmodified lines
141142 }
142143
143144 function compareCheckpointTurnCountDescending(
144− left: OrchestrationCheckpointSummary,
145− right: OrchestrationCheckpointSummary,
145+ left: ThreadCheckpointSummary,
146+ right: ThreadCheckpointSummary,
146147 ): -1 | 0 | 1 {
147148 if (left.checkpointTurnCount === right.checkpointTurnCount) {
148149 return 0;
@@ -151,7 +152,7 @@ function compareCheckpointTurnCountDescending( · 2 unmodified lines
151152 return left.checkpointTurnCount > right.checkpointTurnCount ? -1 : 1;
152153 }
153154
154−const readyCheckpointOrder = Order.make<OrchestrationCheckpointSummary>(
155+const readyCheckpointOrder = Order.make<ThreadCheckpointSummary>(
155156 compareCheckpointTurnCountDescending,
156157 );
157158
@@ -510,14 +511,14 @@ function mapRenderableFile(file: FileDiffMetadata): ReviewRenderableFile { · 352 unmodified lines
510511 }
511512
512513 export function getReviewSectionIdForCheckpoint(
513− checkpoint: Pick<OrchestrationCheckpointSummary, "checkpointTurnCount">,
514+ checkpoint: Pick<ThreadCheckpointSummary, "checkpointTurnCount">,
514515 ): string {
515516 return `turn:${checkpoint.checkpointTurnCount}`;
516517 }
517518
518519 export function getReadyReviewCheckpoints(
519− checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>,
520−): ReadonlyArray<OrchestrationCheckpointSummary> {
520+ checkpoints: ReadonlyArray<ThreadCheckpointSummary>,
521+): ReadonlyArray<ThreadCheckpointSummary> {
521522 return pipe(
522523 checkpoints,
523524 Arr.filter((checkpoint) => checkpoint.status === "ready"),
@@ -526,7 +527,7 @@ export function getReadyReviewCheckpoints( · 2 unmodified lines
526527 }
527528
528529 export function buildReviewSectionItems(input: {
529− readonly checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>;
530+ readonly checkpoints: ReadonlyArray<ThreadCheckpointSummary>;
530531 readonly gitSections: ReadonlyArray<ReviewDiffPreviewSource>;
531532 readonly turnDiffById: Readonly<Record<string, string | undefined>>;
532533 readonly loadingTurnIds: Readonly<Record<string, boolean | undefined>>;
apps/mobile/src/features/review/useReviewSections.ts+3−2
@@ -1,6 +1,7 @@
11 import { useCallback, useEffect, useMemo } from "react";
22
3−import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts";
3+import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/shell";
4+import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
45
56 import { useCheckpointDiff } from "../../state/queries";
67 import { useEnvironmentQuery } from "../../state/query";
@@ -59,7 +60,7 @@ export function useReviewSections(input: { · 52 unmodified lines
5960 getReviewSectionIdForCheckpoint(checkpoint),
6061 checkpoint,
6162 ]),
62− ) as Record<string, OrchestrationCheckpointSummary>,
63+ ) as Record<string, ThreadCheckpointSummary>,
6364 [readyCheckpoints],
6465 );
6566 const reviewSections = useMemo(
apps/mobile/src/features/threads/PendingApprovalCard.tsx+14−6
@@ -1,4 +1,4 @@
1−import type { ApprovalRequestId, ProviderApprovalDecision } from "@t3tools/contracts";
1+import type { ProviderApprovalDecision, RuntimeRequestId } from "@t3tools/contracts";
22 import { Pressable, View } from "react-native";
33
44 import { AppText as Text } from "../../components/AppText";
@@ -6,14 +6,16 @@ import type { PendingApproval } from "../../lib/threadActivity"; · 1 unmodified line
66
77 export interface PendingApprovalCardProps {
88 readonly approval: PendingApproval;
9− readonly respondingApprovalId: ApprovalRequestId | null;
9+ readonly respondingApprovalId: RuntimeRequestId | null;
1010 readonly onRespond: (
11− requestId: ApprovalRequestId,
11+ requestId: RuntimeRequestId,
1212 decision: ProviderApprovalDecision,
1313 ) => Promise<unknown>;
1414 }
1515
1616 export function PendingApprovalCard(props: PendingApprovalCardProps) {
17+ const canRespond = props.approval.responseCapability === "live";
18+ const disabled = !canRespond || props.respondingApprovalId === props.approval.requestId;
1719 return (
1820 <View className="gap-2.5 rounded-[20px] border border-neutral-200 bg-neutral-100/80 p-4 dark:border-white/6 dark:bg-neutral-900/80">
1921 <Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-sky-700 dark:text-sky-300">
@@ -27,17 +29,23 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { · 7 unmodified lines
2729 {props.approval.detail}
2830 </Text>
2931 ) : null}
32+ {!canRespond ? (
33+ <Text className="font-sans text-sm leading-5 text-neutral-600 dark:text-neutral-400">
34+ The provider process for this request is no longer available. Interrupt or restart the run
35+ to continue.
36+ </Text>
37+ ) : null}
3038 <View className="flex-row flex-wrap gap-2.5">
3139 <Pressable
3240 className="items-center justify-center rounded-[14px] bg-blue-500 px-3.5 py-3"
33− disabled={props.respondingApprovalId === props.approval.requestId}
41+ disabled={disabled}
3442 onPress={() => void props.onRespond(props.approval.requestId, "accept")}
3543 >
3644 <Text className="font-t3-extrabold text-sm text-white">Allow once</Text>
3745 </Pressable>
3846 <Pressable
3947 className="items-center justify-center rounded-[14px] bg-neutral-200 px-3.5 py-3 dark:bg-neutral-800"
40− disabled={props.respondingApprovalId === props.approval.requestId}
48+ disabled={disabled}
4149 onPress={() => void props.onRespond(props.approval.requestId, "acceptForSession")}
4250 >
4351 <Text className="font-t3-bold text-sm text-neutral-950 dark:text-neutral-50">
@@ -46,7 +54,7 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { · 2 unmodified lines
4654 </Pressable>
4755 <Pressable
4856 className="items-center justify-center rounded-[14px] bg-rose-100 px-3.5 py-3 dark:bg-rose-500/18"
49− disabled={props.respondingApprovalId === props.approval.requestId}
57+ disabled={disabled}
5058 onPress={() => void props.onRespond(props.approval.requestId, "decline")}
5159 >
5260 <Text className="font-t3-bold text-sm text-rose-700 dark:text-rose-300">Decline</Text>
apps/mobile/src/features/threads/PendingUserInputCard.tsx+16−9
@@ -1,4 +1,4 @@
1−import type { ApprovalRequestId } from "@t3tools/contracts";
1+import type { RuntimeRequestId } from "@t3tools/contracts";
22 import { Pressable, View } from "react-native";
33
44 import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
@@ -9,14 +9,10 @@ export interface PendingUserInputCardProps { · 4 unmodified lines
99 readonly pendingUserInput: PendingUserInput;
1010 readonly drafts: Record<string, PendingUserInputDraftAnswer>;
1111 readonly answers: Record<string, string> | null;
12− readonly respondingUserInputId: ApprovalRequestId | null;
13− readonly onSelectOption: (
14− requestId: ApprovalRequestId,
15− questionId: string,
16− label: string,
17− ) => void;
12+ readonly respondingUserInputId: RuntimeRequestId | null;
13+ readonly onSelectOption: (requestId: RuntimeRequestId, questionId: string, label: string) => void;
1814 readonly onChangeCustomAnswer: (
19− requestId: ApprovalRequestId,
15+ requestId: RuntimeRequestId,
2016 questionId: string,
2117 customAnswer: string,
2218 ) => void;
@@ -24,6 +20,7 @@ export interface PendingUserInputCardProps { · 1 unmodified line
2420 }
2521
2622 export function PendingUserInputCard(props: PendingUserInputCardProps) {
23+ const canRespond = props.pendingUserInput.responseCapability === "live";
2724 return (
2825 <View className="gap-2.5 rounded-[20px] border border-neutral-200 bg-neutral-100/80 p-4 dark:border-white/6 dark:bg-neutral-900/80">
2926 <Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-sky-700 dark:text-sky-300">
@@ -32,6 +29,12 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 2 unmodified lines
3229 <Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50">
3330 Fill in the pending answers
3431 </Text>
32+ {!canRespond ? (
33+ <Text className="font-sans text-sm leading-5 text-neutral-600 dark:text-neutral-400">
34+ The provider process for this request is no longer available. Interrupt or restart the run
35+ to continue.
36+ </Text>
37+ ) : null}
3538 {props.pendingUserInput.questions.map((question) => {
3639 const draft = props.drafts[question.id];
3740 return (
@@ -49,6 +52,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 11 unmodified lines
4952 return (
5053 <Pressable
5154 key={option.label}
55+ disabled={!canRespond}
5256 className={cn(
5357 "rounded-full border px-3 py-2.5 ",
5458 selected
@@ -78,6 +82,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 23 unmodified lines
7882 })}
7983 </View>
8084 <TextInput
85+ editable={canRespond}
8186 value={draft?.customAnswer ?? ""}
8287 onChangeText={(value) =>
8388 props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value)
@@ -94,7 +99,9 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 10 unmodified lines
9499 props.answers ? "bg-blue-500" : "bg-neutral-200 dark:bg-neutral-700/60",
95100 )}
96101 disabled={
97− props.answers === null || props.respondingUserInputId === props.pendingUserInput.requestId
102+ !canRespond ||
103+ props.answers === null ||
104+ props.respondingUserInputId === props.pendingUserInput.requestId
98105 }
99106 onPress={() => void props.onSubmit()}
100107 >
apps/mobile/src/features/threads/ThreadComposer.tsx+6−11
@@ -1,9 +1,12 @@
11 import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
2+import {
3+ threadRuntimeIsActive,
4+ type EnvironmentThreadShell,
5+} from "@t3tools/client-runtime/state/shell";
26 import type {
37 EnvironmentId,
48 MessageId,
59 ModelSelection,
6− OrchestrationThreadShell,
710 ProviderInteractionMode,
811 RuntimeMode,
912 ServerConfig as T3ServerConfig,
@@ -91,13 +94,7 @@ export interface ThreadComposerProps { · 81 unmodified lines
9194 readonly connectionState: RemoteClientConnectionState;
9295 readonly connectionError: string | null;
9396 readonly environmentLabel: string | null;
94− /**
95− * Message sync phase for the selected thread (drives the status pill):
96− * "loading" = first fetch, nothing to show yet; "syncing" = cached messages
97− * are on screen while they reconcile with the server.
98− */
99− readonly threadSyncPhase?: "loading" | "syncing" | null;
100− readonly selectedThread: OrchestrationThreadShell;
97+ readonly selectedThread: EnvironmentThreadShell;
10198 readonly serverConfig: T3ServerConfig | null;
10299 readonly queueCount: number;
103100 readonly activeThreadBusy: boolean;
@@ -305,9 +302,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 201 unmodified lines
305302 setIsFocused(false);
306303 onExpandedChange?.(false);
307304 }, [onExpandedChange]);
308− const showStopAction =
309− props.selectedThread.session?.status === "running" ||
310− props.selectedThread.session?.status === "starting";
305+ const showStopAction = threadRuntimeIsActive(props.selectedThread.runtime);
311306
312307 const sendLabel =
313308 props.connectionState !== "connected" || props.activeThreadBusy || props.queueCount > 0
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+51−44
@@ -1,16 +1,15 @@
11 import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
2−import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";
2+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
33 import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
44 import type { LegendListRef } from "@legendapp/list/react-native";
55 import type {
6− ApprovalRequestId,
76 EnvironmentId,
87 MessageId,
98 ModelSelection,
10− OrchestrationThreadShell,
119 ProviderApprovalDecision,
1210 ProviderInteractionMode,
1311 RuntimeMode,
12+ RuntimeRequestId,
1413 ServerConfig as T3ServerConfig,
1514 ThreadId,
1615 } from "@t3tools/contracts";
@@ -43,7 +42,7 @@ import { ThreadFeed } from "./ThreadFeed"; · 26 unmodified lines
4342 import type { ThreadContentPresentation } from "./threadContentPresentation";
4443
4544 export interface ThreadDetailScreenProps {
46− readonly selectedThread: OrchestrationThreadShell;
45+ readonly selectedThread: EnvironmentThreadShell;
4746 readonly contentPresentation: ThreadContentPresentation;
4847 readonly screenTone: StatusTone;
4948 readonly connectionError: string | null;
@@ -51,11 +50,11 @@ export interface ThreadDetailScreenProps { · 1 unmodified line
5150 readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>;
5251 readonly activeWorkStartedAt: string | null;
5352 readonly activePendingApproval: PendingApproval | null;
54− readonly respondingApprovalId: ApprovalRequestId | null;
53+ readonly respondingApprovalId: RuntimeRequestId | null;
5554 readonly activePendingUserInput: PendingUserInput | null;
5655 readonly activePendingUserInputDrafts: Record<string, PendingUserInputDraftAnswer>;
5756 readonly activePendingUserInputAnswers: Record<string, string> | null;
58− readonly respondingUserInputId: ApprovalRequestId | null;
57+ readonly respondingUserInputId: RuntimeRequestId | null;
5958 readonly draftMessage: string;
6059 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
6160 readonly connectionStateLabel: EnvironmentConnectionPhase;
@@ -82,16 +81,16 @@ export interface ThreadDetailScreenProps { · 20 unmodified lines
8281 readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void;
8382 readonly onUpdateThreadInteractionMode: (interactionMode: ProviderInteractionMode) => void;
8483 readonly onRespondToApproval: (
85− requestId: ApprovalRequestId,
84+ requestId: RuntimeRequestId,
8685 decision: ProviderApprovalDecision,
8786 ) => Promise<unknown>;
8887 readonly onSelectUserInputOption: (
89− requestId: ApprovalRequestId,
88+ requestId: RuntimeRequestId,
9089 questionId: string,
9190 label: string,
9291 ) => void;
9392 readonly onChangeUserInputCustomAnswer: (
94− requestId: ApprovalRequestId,
93+ requestId: RuntimeRequestId,
9594 questionId: string,
9695 customAnswer: string,
9796 ) => void;
@@ -341,41 +340,49 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 243 unmodified lines
341340 }, []);
342341
343342 return (
344− <View className="flex-1">
345− {showContent ? (
346− <View
347− className="flex-1"
348− onTouchStart={handleFeedTouchStart}
349− onTouchMove={handleFeedTouchMove}
350− onTouchEnd={handleFeedTouchEnd}
351− onTouchCancel={handleFeedTouchCancel}
352− >
353− <ThreadFeed
354− key={props.selectedThread.id}
355− environmentId={props.environmentId}
356− threadId={props.selectedThread.id}
357− workspaceRoot={props.threadCwd}
358− feed={props.selectedThreadFeed}
359− contentPresentation={props.contentPresentation}
360− agentLabel={agentLabel}
361− latestTurn={props.selectedThread.latestTurn}
362− activeWorkStartedAt={props.activeWorkStartedAt}
363− listRef={listRef}
364− freeze={freeze}
365− anchorMessageId={anchorMessageId}
366− contentInsetEndAdjustment={contentInsetEndAdjustment}
367− contentTopInset={0}
368− contentBottomInset={estimatedOverlayHeight}
369− contentMaxWidth={contentMaxWidth}
370− layoutVariant={layoutVariant}
371− usesAutomaticContentInsets={props.usesAutomaticContentInsets}
372− onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
373− skills={selectedProviderSkills}
374− />
375− </View>
376− ) : (
377− <View className="flex-1" />
378− )}
343+ <GestureDetector gesture={headerDrawerGesture}>
344+ <View className="flex-1">
345+ {showContent ? (
346+ <View
347+ style={{ flex: 1 }}
348+ onTouchStart={handleFeedTouchStart}
349+ onTouchMove={handleFeedTouchMove}
350+ onTouchEnd={handleFeedTouchEnd}
351+ onTouchCancel={handleFeedTouchCancel}
352+ >
353+ <ThreadFeed
354+ key={props.selectedThread.id}
355+ environmentId={props.environmentId}
356+ threadId={props.selectedThread.id}
357+ workspaceRoot={props.threadCwd}
358+ feed={props.selectedThreadFeed}
359+ contentPresentation={props.contentPresentation}
360+ agentLabel={agentLabel}
361+ latestRun={props.selectedThread.latestRun}
362+ listRef={listRef}
363+ freeze={freeze}
364+ anchorMessageId={anchorMessageId}
365+ contentInsetEndAdjustment={contentInsetEndAdjustment}
366+ contentTopInset={headerHeight}
367+ contentBottomInset={estimatedOverlayHeight}
368+ layoutVariant={layoutVariant}
369+ skills={selectedProviderSkills}
370+ />
371+ </View>
372+ ) : (
373+ <View style={{ flex: 1 }} />
374+ )}
375+
376+ {/* Floating composer — sticks to keyboard via KeyboardStickyView */}
377+ {showContent ? (
378+ <KeyboardStickyView
379+ style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
380+ offset={{ closed: 0, opened: 0 }}
381+ >
382+ <View ref={composerOverlayRef} onLayout={onComposerLayout} style={{ paddingTop: 8 }}>
383+ {props.activeWorkStartedAt ? (
384+ <WorkingDurationPill startedAt={props.activeWorkStartedAt} />
385+ ) : null}
379386
380387 {/* Floating composer — sticks to keyboard via KeyboardStickyView */}
381388 {showContent ? (
apps/mobile/src/features/threads/ThreadFeed.tsx+51−58
@@ -1,7 +1,7 @@
11 import * as Haptics from "expo-haptics";
22 import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
33 import { type LegendListRef } from "@legendapp/list/react-native";
4−import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";
4+import type { EnvironmentId, MessageId, RunId, ThreadId } from "@t3tools/contracts";
55 import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
66 import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
77 import { SymbolView } from "../../components/AppSymbol";
@@ -92,7 +92,7 @@ import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/l · 84 unmodified lines
9292 import {
9393 deriveThreadFeedPresentation,
9494 type ThreadFeedEntry,
95− type ThreadFeedLatestTurn,
95+ type ThreadFeedLatestRun,
9696 } from "../../lib/threadActivity";
9797 import type { ThreadContentPresentation } from "./threadContentPresentation";
9898 import {
@@ -151,8 +151,7 @@ export interface ThreadFeedProps { · 52 unmodified lines
151151 readonly feed: ReadonlyArray<ThreadFeedEntry>;
152152 readonly contentPresentation: ThreadContentPresentation;
153153 readonly agentLabel: string;
154− readonly latestTurn: ThreadFeedLatestTurn | null;
155− readonly activeWorkStartedAt: string | null;
154+ readonly latestRun: ThreadFeedLatestRun | null;
156155 readonly listRef: RefObject<LegendListRef | null>;
157156 readonly freeze: SharedValue<boolean>;
158157 readonly anchorMessageId: MessageId | null;
@@ -823,11 +822,11 @@ function renderFeedEntry( · 664 unmodified lines
823822 readonly copiedRowId: string | null;
824823 readonly expandedWorkRows: Record<string, boolean>;
825824 readonly terminalAssistantMessageIds: ReadonlySet<string>;
826− readonly unsettledTurnId: TurnId | null;
825+ readonly unsettledTurnId: RunId | null;
827826 readonly onCopyWorkRow: (rowId: string, value: string) => void;
828827 readonly onToggleWorkGroup: (groupId: string) => void;
829828 readonly onToggleWorkRow: (rowId: string) => void;
830− readonly onToggleTurnFold: (turnId: TurnId) => void;
829+ readonly onToggleTurnFold: (runId: RunId) => void;
831830 readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;
832831 readonly onMarkdownLinkPress: (href: string) => void;
833832 readonly iconSubtleColor: string | import("react-native").ColorValue;
@@ -841,16 +840,12 @@ function renderFeedEntry( · 7 unmodified lines
841840 const entry = info.item;
842841 const { markdownStyles, iconSubtleColor, userBubbleColor } = props;
843842
844− if (entry.type === "working") {
845− return <WorkingTimelineRow startedAt={entry.createdAt} />;
846− }
847−
848− if (entry.type === "turn-fold") {
843+ if (entry.type === "run-fold") {
849844 return (
850845 <Pressable
851846 accessibilityRole="button"
852847 accessibilityState={{ expanded: entry.expanded }}
853− onPress={() => props.onToggleTurnFold(entry.turnId)}
848+ onPress={() => props.onToggleTurnFold(entry.runId)}
854849 hitSlop={4}
855850 className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-neutral-200/80 px-2 dark:border-white/[0.08]"
856851 >
@@ -889,7 +884,7 @@ function renderFeedEntry( · 32 unmodified lines
889884 const assistantTurnStillInProgress =
890885 message.role === "assistant" &&
891886 props.unsettledTurnId !== null &&
892− message.turnId === props.unsettledTurnId;
887+ message.runId === props.unsettledTurnId;
893888 const showAssistantMeta =
894889 message.role === "assistant" &&
895890 props.terminalAssistantMessageIds.has(message.id) &&
@@ -1320,21 +1315,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 424 unmodified lines
13201315 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
13211316 const foldSettleFrameRef = useRef<number | null>(null);
13221317 const foldSettleSecondFrameRef = useRef<number | null>(null);
1323− const disclosureAnchorKeyRef = useRef<string | null>(null);
1324− const headerMaterialVisibleRef = useRef(false);
1325− const previousLatestTurnRef = useRef(props.latestTurn);
1326− const { width: windowWidth } = useWindowDimensions();
1327− const { appearance } = useAppearancePreferences();
1328− const [viewportWidth, setViewportWidth] = useState(() =>
1329− props.layoutVariant === "split" ? 0 : windowWidth,
1330− );
1331− const [viewportHeight, setViewportHeight] = useState(0);
1332− const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
1318+ const previousLatestTurnRef = useRef(props.latestRun);
1319+ const { width: viewportWidth } = useWindowDimensions();
1320+ const [foldToggleSettling, setFoldToggleSettling] = useState(false);
13331321 const [interactionState, setInteractionState] = useState<{
13341322 readonly copiedRowId: string | null;
13351323 readonly expandedWorkGroups: Record<string, boolean>;
13361324 readonly expandedWorkRows: Record<string, boolean>;
1337− readonly expandedTurnIds: ReadonlySet<TurnId>;
1325+ readonly expandedTurnIds: ReadonlySet<RunId>;
13381326 }>({
13391327 copiedRowId: null,
13401328 expandedWorkGroups: {},
@@ -1424,15 +1412,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 83 unmodified lines
14241412 viewportWidth,
14251413 ],
14261414 );
1427− const reportHeaderMaterialVisibility = useCallback(
1428− (visible: boolean) => {
1429− if (headerMaterialVisibleRef.current === visible) {
1430− return;
1431− }
1432− headerMaterialVisibleRef.current = visible;
1433− props.onHeaderMaterialVisibilityChange?.(visible);
1434− },
1435− [props.onHeaderMaterialVisibilityChange],
1415+ const presentedFeed = useMemo(
1416+ () => deriveThreadFeedPresentation(props.feed, props.latestRun, expandedTurnIds),
1417+ [expandedTurnIds, props.feed, props.latestRun],
14361418 );
14371419 // True while the viewport sits within ~one screen of the list end — the
14381420 // only region where layout shifts should animate. Starts true because the
@@ -1544,29 +1526,29 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 105 unmodified lines
15441526 [presentedFeed, props.anchorMessageId, anchorTopInset],
15451527 );
15461528 const terminalAssistantMessageIds = useMemo(() => {
1547− const terminalIdsByTurn = new Map<TurnId, string>();
1529+ const terminalIdsByTurn = new Map<RunId, string>();
15481530 for (const entry of props.feed) {
1549− if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) {
1550− terminalIdsByTurn.set(entry.message.turnId, entry.message.id);
1531+ if (entry.type === "message" && entry.message.role === "assistant" && entry.message.runId) {
1532+ terminalIdsByTurn.set(entry.message.runId, entry.message.id);
15511533 }
15521534 }
15531535 return new Set(terminalIdsByTurn.values());
15541536 }, [props.feed]);
15551537 const unsettledTurnId =
1556− props.latestTurn &&
1557− (props.latestTurn.completedAt === null || props.latestTurn.state === "running")
1558− ? props.latestTurn.turnId
1538+ props.latestRun &&
1539+ (props.latestRun.completedAt === null || props.latestRun.status === "running")
1540+ ? props.latestRun.runId
15591541 : null;
15601542
15611543 useEffect(() => {
15621544 const previous = previousLatestTurnRef.current;
1563− previousLatestTurnRef.current = props.latestTurn;
1564− if (!props.latestTurn || !previous) {
1545+ previousLatestTurnRef.current = props.latestRun;
1546+ if (!props.latestRun || !previous) {
15651547 return;
15661548 }
1567− if (props.latestTurn.turnId === previous.turnId) {
1568− if (previous.state === "running" && props.latestTurn.state === "interrupted") {
1569− const interruptedTurnId = props.latestTurn.turnId;
1549+ if (props.latestRun.runId === previous.runId) {
1550+ if (previous.status === "running" && props.latestRun.status === "interrupted") {
1551+ const interruptedTurnId = props.latestRun.runId;
15701552 setInteractionState((current) => ({
15711553 ...current,
15721554 expandedTurnIds: new Set(current.expandedTurnIds).add(interruptedTurnId),
@@ -1575,14 +1557,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 2 unmodified lines
15751557 return;
15761558 }
15771559 setInteractionState((current) => {
1578− if (!current.expandedTurnIds.has(previous.turnId)) {
1560+ if (!current.expandedTurnIds.has(previous.runId)) {
15791561 return current;
15801562 }
15811563 const next = new Set(current.expandedTurnIds);
1582− next.delete(previous.turnId);
1564+ next.delete(previous.runId);
15831565 return { ...current, expandedTurnIds: next };
15841566 });
1585− }, [props.latestTurn]);
1567+ }, [props.latestRun]);
15861568
15871569 useEffect(() => {
15881570 return () => {
@@ -1676,17 +1658,28 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 87 unmodified lines
16761658 [suspendEndScrollMaintenanceForDisclosure],
16771659 );
16781660
1679− const onToggleTurnFold = useCallback(
1680− (turnId: TurnId) => {
1681− suspendEndScrollMaintenanceForDisclosure(`turn-fold:${turnId}`);
1682− setInteractionState((current) => {
1683− const next = new Set(current.expandedTurnIds);
1684− if (next.has(turnId)) {
1685− next.delete(turnId);
1686− } else {
1687− next.add(turnId);
1688− }
1689− return { ...current, expandedTurnIds: next };
1661+ const onToggleTurnFold = useCallback((runId: RunId) => {
1662+ setFoldToggleSettling(true);
1663+ if (foldSettleFrameRef.current !== null) {
1664+ cancelAnimationFrame(foldSettleFrameRef.current);
1665+ }
1666+ if (foldSettleSecondFrameRef.current !== null) {
1667+ cancelAnimationFrame(foldSettleSecondFrameRef.current);
1668+ }
1669+ setInteractionState((current) => {
1670+ const next = new Set(current.expandedTurnIds);
1671+ if (next.has(runId)) {
1672+ next.delete(runId);
1673+ } else {
1674+ next.add(runId);
1675+ }
1676+ return { ...current, expandedTurnIds: next };
1677+ });
1678+ foldSettleFrameRef.current = requestAnimationFrame(() => {
1679+ foldSettleSecondFrameRef.current = requestAnimationFrame(() => {
1680+ setFoldToggleSettling(false);
1681+ foldSettleFrameRef.current = null;
1682+ foldSettleSecondFrameRef.current = null;
16901683 });
16911684 },
16921685 [suspendEndScrollMaintenanceForDisclosure],
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+10−20
@@ -1,11 +1,6 @@
1−import { NativeStackScreenOptions } from "../../native/StackHeader";
2−import {
3− StackActions,
4− useFocusEffect,
5− useNavigation,
6− type StaticScreenProps,
7−} from "@react-navigation/native";
8−import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
1+import { Stack, useLocalSearchParams, useRouter } from "expo-router";
2+import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell";
3+import { useCallback, useMemo, useState } from "react";
94 import * as Option from "effect/Option";
105 import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts";
116 import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
@@ -461,20 +456,15 @@ function ThreadRouteContent( · 449 unmodified lines
461456 void navigation.navigate("Connections");
462457 }, [navigation]);
463458 const handleStopThread = useCallback(() => {
464− if (
465− !selectedThread ||
466− (selectedThread.session?.status !== "running" &&
467− selectedThread.session?.status !== "starting")
468− ) {
459+ const runtime = selectedThread?.runtime;
460+ if (!selectedThread || !runtime || !threadRuntimeIsActive(runtime)) {
469461 return;
470462 }
471463 return interruptThreadTurn({
472464 environmentId: selectedThread.environmentId,
473465 input: {
474466 threadId: selectedThread.id,
475− ...(selectedThread.session.activeTurnId
476− ? { turnId: selectedThread.session.activeTurnId }
477− : {}),
467+ ...(runtime.activeRunId ? { runId: runtime.activeRunId } : {}),
478468 },
479469 });
480470 }, [interruptThreadTurn, selectedThread]);
@@ -504,7 +494,7 @@ function ThreadRouteContent( · 23 unmodified lines
504494 terminalDebugLog("terminal-menu:open-new", {
505495 hasThread: Boolean(selectedThread),
506496 hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot),
507− listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
497+ listedTerminalIds: terminalMenuSessions.map((runtime) => runtime.terminalId),
508498 });
509499
510500 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {
@@ -512,7 +502,7 @@ function ThreadRouteContent( · 1 unmodified line
512502 }
513503
514504 const nextId = nextOpenTerminalId({
515− listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
505+ listedTerminalIds: terminalMenuSessions.map((runtime) => runtime.terminalId),
516506 });
517507 void navigation.navigate("ThreadTerminal", {
518508 environmentId: String(selectedThread.environmentId),
@@ -539,9 +529,9 @@ function ThreadRouteContent( · 20 unmodified lines
539529 }
540530
541531 const targetTerminalId = resolveProjectScriptTerminalId({
542− existingTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
532+ existingTerminalIds: terminalMenuSessions.map((runtime) => runtime.terminalId),
543533 hasRunningTerminal: terminalMenuSessions.some(
544− (session) => session.status === "running" || session.status === "starting",
534+ (runtime) => runtime.status === "running" || runtime.status === "starting",
545535 ),
546536 });
547537 const preferredWorktreePath = resolvePreferredThreadWorktreePath({
apps/mobile/src/features/threads/threadPresentation.ts+6−49
@@ -7,49 +7,9 @@ export function threadSortValue(thread: EnvironmentThreadShell): number { · 6 unmodified lines
77 return Number.isNaN(candidate) ? 0 : candidate;
88 }
99
10−export type ThreadStatusKind =
11− | "pending-approval"
12− | "awaiting-input"
13− | "working"
14− | "connecting"
15− | "error"
16− | "plan-ready";
17−
18−export interface ThreadStatusPresentation extends StatusTone {
19− readonly kind: ThreadStatusKind;
20− /** Foreground color for the leading status icon. */
21− readonly iconColor: string;
22− /** Background color for the leading status icon circle. */
23− readonly iconBackground: string;
24− /** Whether the indicator represents in-flight activity. */
25− readonly pulse: boolean;
26−}
27−
28−/** Neutral icon colors for threads with no actionable status. */
29−export const THREAD_STATUS_NEUTRAL_ICON = {
30− iconColor: "#8e8e93",
31− iconBackground: "rgba(142,142,147,0.22)",
32−} as const;
33−
34−function isLatestTurnSettled(
35− latestTurn: OrchestrationLatestTurn | null,
36− session: OrchestrationSession | null,
37−): boolean {
38− if (!latestTurn?.startedAt) return false;
39− if (!latestTurn.completedAt) return false;
40− if (!session) return true;
41− return session.status !== "running";
42−}
43−
44−/**
45− * Resolves the user-facing status of a thread, in priority order. Returns
46− * `null` for quiescent threads so rows stay free of "Idle"-style noise.
47− * Mirrors `resolveThreadStatusPill` in apps/web/src/components/Sidebar.logic.ts.
48− */
49−export function resolveThreadStatus(
50− thread: EnvironmentThreadShell,
51−): ThreadStatusPresentation | null {
52− if (thread.hasPendingApprovals) {
10+export function threadStatusTone(thread: EnvironmentThreadShell): StatusTone {
11+ const status = thread.runtime?.status;
12+ if (status === "running" || status === "waiting") {
5313 return {
5414 kind: "pending-approval",
5515 label: "Needs Approval",
@@ -60,8 +20,7 @@ export function resolveThreadStatus( · 4 unmodified lines
6020 pulse: false,
6121 };
6222 }
63−
64− if (thread.hasPendingUserInput) {
23+ if (status === "completed") {
6524 return {
6625 kind: "awaiting-input",
6726 label: "Awaiting Input",
@@ -72,8 +31,7 @@ export function resolveThreadStatus( · 4 unmodified lines
7231 pulse: false,
7332 };
7433 }
75−
76− if (thread.session?.status === "running") {
34+ if (status === "queued" || status === "starting") {
7735 return {
7836 kind: "working",
7937 label: "Working",
@@ -84,8 +42,7 @@ export function resolveThreadStatus( · 4 unmodified lines
8442 pulse: true,
8543 };
8644 }
87−
88− if (thread.session?.status === "starting") {
45+ if (status === "failed") {
8946 return {
9047 kind: "connecting",
9148 label: "Connecting",
apps/mobile/src/features/threads/use-project-actions.ts+10−9
@@ -58,15 +58,16 @@ export function useCreateProjectThread() { · 57 unmodified lines
5858
5959 const result = await startTurn({
6060 environmentId: input.project.environmentId,
61− input: buildProjectThreadStartTurnInput({
62− projectId: input.project.id,
63− projectCwd: input.project.workspaceRoot,
64− threadId: metadata.threadId,
65− commandId: metadata.commandId,
66− messageId: metadata.messageId,
67− createdAt: metadata.createdAt,
68− text: initialMessageText,
69− attachments: input.initialAttachments,
61+ input: {
62+ commandId: CommandId.make(metadata.commandId),
63+ creationSource: "mobile",
64+ threadId,
65+ message: {
66+ messageId: MessageId.make(metadata.messageId),
67+ role: "user",
68+ text: initialMessageText,
69+ attachments: input.initialAttachments,
70+ },
7071 modelSelection: input.modelSelection,
7172 runtimeMode: input.runtimeMode,
7273 interactionMode: input.interactionMode,
apps/mobile/src/lib/repositoryGroups.test.ts+3−6
@@ -4,6 +4,7 @@ import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools · 3 unmodified lines
44
55 import { groupProjectsByRepository } from "./repositoryGroups";
66 import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
7+import { makeThreadShellFixture } from "../test-fixtures";
78
89 function makeProject(
910 input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">,
@@ -23,24 +24,20 @@ function makeThread( · 13 unmodified lines
2324 input: Partial<EnvironmentThreadShell> &
2425 Pick<EnvironmentThreadShell, "environmentId" | "id" | "projectId" | "title" | "modelSelection">,
2526 ): EnvironmentThreadShell {
26− return {
27+ return makeThreadShellFixture({
2728 runtimeMode: "full-access",
2829 interactionMode: "default",
2930 branch: null,
3031 worktreePath: null,
31− latestTurn: null,
3232 createdAt: "2026-04-01T00:00:00.000Z",
3333 updatedAt: "2026-04-01T00:00:00.000Z",
3434 archivedAt: null,
35− session: null,
3635 latestUserMessageAt: null,
3736 hasPendingApprovals: false,
3837 hasPendingUserInput: false,
3938 hasActionableProposedPlan: false,
4039 ...input,
41− settledOverride: input.settledOverride ?? null,
42− settledAt: input.settledAt ?? null,
43− };
40+ });
4441 }
4542
4643 describe("groupProjectsByRepository", () => {
apps/mobile/src/lib/scopedEntities.ts+2−2
@@ -1,4 +1,4 @@
1−import { ApprovalRequestId, EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
1+import { EnvironmentId, ProjectId, RuntimeRequestId, ThreadId } from "@t3tools/contracts";
22
33 export function scopedProjectKey(environmentId: EnvironmentId, projectId: ProjectId): string {
44 return `${environmentId}:${projectId}`;
@@ -10,7 +10,7 @@ export function scopedThreadKey(environmentId: EnvironmentId, threadId: ThreadId · 5 unmodified lines
1010
1111 export function scopedRequestKey(
1212 environmentId: EnvironmentId,
13− requestId: ApprovalRequestId,
13+ requestId: RuntimeRequestId,
1414 ): string {
1515 return `${environmentId}:${requestId}`;
1616 }
apps/mobile/src/lib/threadActivity.test.ts+79−433
@@ -1,466 +1,112 @@
1+import type { ThreadWorkEntry } from "@t3tools/client-runtime/state/shell";
2+import { MessageId, RunId } from "@t3tools/contracts";
13 import { describe, expect, it } from "vite-plus/test";
24
3−import {
4− EventId,
5− MessageId,
6− ProjectId,
7− ProviderInstanceId,
8− ThreadId,
9− TurnId,
10− type OrchestrationThread,
11− type OrchestrationThreadActivity,
12−} from "@t3tools/contracts";
5+import { makeThreadFixture } from "../test-fixtures";
6+import { buildThreadFeed, deriveThreadFeedPresentation } from "./threadActivity";
137
14−import {
15− buildThreadFeed,
16− deriveThreadFeedPresentation,
17− type ThreadFeedActivity,
18− type ThreadFeedEntry,
19−} from "./threadActivity";
8+const runId = RunId.make("run-1");
209
21−function makeActivity(
22− input: Partial<OrchestrationThreadActivity> &
23− Pick<OrchestrationThreadActivity, "id" | "kind" | "summary" | "createdAt">,
24−): OrchestrationThreadActivity {
10+function message(role: "user" | "assistant", text: string, createdAt: string, id: string) {
2511 return {
26− tone: "info",
27− payload: {},
28− turnId: null,
29− ...input,
30− };
12+ id: MessageId.make(id),
13+ role,
14+ text,
15+ attachments: [],
16+ runId: role === "assistant" ? runId : null,
17+ streaming: false,
18+ createdAt,
19+ updatedAt: createdAt,
20+ } as const;
3121 }
3222
33−function makeThread(
34− input: Partial<OrchestrationThread> & Pick<OrchestrationThread, "id" | "projectId" | "title">,
35−): OrchestrationThread {
23+function commandEntry(overrides: Partial<ThreadWorkEntry> = {}): ThreadWorkEntry {
24+ const structuredPayload = {
25+ type: "command_execution",
26+ input: "vp check",
27+ output: "ok",
28+ } as ThreadWorkEntry["structuredPayload"];
3629 return {
37− modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
38− runtimeMode: "full-access",
39− interactionMode: "default",
40− branch: null,
41− worktreePath: null,
42− latestTurn: null,
43− createdAt: "2026-04-01T00:00:00.000Z",
44− updatedAt: "2026-04-01T00:00:00.000Z",
45− archivedAt: null,
46− deletedAt: null,
47− messages: [],
48− proposedPlans: [],
49− activities: [],
50− checkpoints: [],
51− session: null,
52− ...input,
53− settledOverride: input.settledOverride ?? null,
54− settledAt: input.settledAt ?? null,
30+ id: "item-1",
31+ createdAt: "2026-06-20T00:00:02.000Z",
32+ runId,
33+ label: "Ran command",
34+ command: "vp check",
35+ detail: "ok",
36+ tone: "tool",
37+ itemType: "command_execution",
38+ toolLifecycleStatus: "completed",
39+ structuredPayload,
40+ ...overrides,
5541 };
5642 }
5743
5844 describe("buildThreadFeed", () => {
59− it("keeps historic work entries attributed to their turns", () => {
60− const thread = makeThread({
61− id: ThreadId.make("thread-1"),
62− projectId: ProjectId.make("project-1"),
63− title: "Runtime warning thread",
64− latestTurn: {
65− turnId: TurnId.make("turn-latest"),
66− state: "running",
67− requestedAt: "2026-04-01T00:00:00.000Z",
68− startedAt: "2026-04-01T00:00:01.000Z",
69− completedAt: null,
70− assistantMessageId: null,
71− },
72− activities: [
73− makeActivity({
74− id: EventId.make("activity-old"),
75− kind: "runtime.warning",
76− summary: "Runtime warning",
77− createdAt: "2026-04-01T00:00:02.000Z",
78− turnId: TurnId.make("turn-old"),
79− payload: {
80− message: "Old warning",
81− },
82− }),
83− makeActivity({
84− id: EventId.make("activity-latest"),
85− kind: "runtime.warning",
86− summary: "Runtime warning",
87− createdAt: "2026-04-01T00:00:03.000Z",
88− turnId: TurnId.make("turn-latest"),
89− payload: {
90− message: "Latest warning",
91− },
92− }),
93− ],
94− });
95−
96− const feed = buildThreadFeed(thread);
97− expect(feed).toMatchObject([
98− {
99− type: "activity-group",
100− turnId: "turn-old",
101− activities: [{ id: "activity-old", turnId: "turn-old" }],
102− },
103− {
104− type: "activity-group",
105− turnId: "turn-latest",
106− activities: [{ id: "activity-latest", turnId: "turn-latest" }],
107− },
108− ]);
109− });
110−
111− it("collapses matching tool lifecycle rows like desktop", () => {
112− const thread = makeThread({
113− id: ThreadId.make("thread-2"),
114− projectId: ProjectId.make("project-1"),
115− title: "Collapsed tools",
116− latestTurn: {
117− turnId: TurnId.make("turn-1"),
118− state: "completed",
119− requestedAt: "2026-04-01T00:00:00.000Z",
120− startedAt: "2026-04-01T00:00:01.000Z",
121− completedAt: "2026-04-01T00:00:03.000Z",
122− assistantMessageId: null,
123− },
124− activities: [
125− makeActivity({
126− id: EventId.make("tool-updated"),
127− kind: "tool.updated",
128− tone: "tool",
129− summary: "Run tests",
130− createdAt: "2026-04-01T00:00:01.000Z",
131− turnId: TurnId.make("turn-1"),
132− payload: {
133− title: "Run tests",
134− itemType: "command_execution",
135− detail: "/bin/zsh -lc 'bun run test'",
136− },
137− }),
138− makeActivity({
139− id: EventId.make("tool-completed"),
140− kind: "tool.completed",
141− tone: "tool",
142− summary: "Run tests completed",
143− createdAt: "2026-04-01T00:00:02.000Z",
144− turnId: TurnId.make("turn-1"),
145− payload: {
146− title: "Run tests",
147− itemType: "command_execution",
148− detail: "/bin/zsh -lc 'bun run test'",
149− },
150− }),
45+ it("orders V2 messages and work entries while retaining structured tool data", () => {
46+ const thread = makeThreadFixture({
47+ messages: [
48+ message("user", "Run checks", "2026-06-20T00:00:01.000Z", "message-user"),
49+ message("assistant", "Done", "2026-06-20T00:00:03.000Z", "message-assistant"),
15150 ],
51+ workEntries: [commandEntry()],
15252 });
15353
15454 const feed = buildThreadFeed(thread);
155− const group = feed[0];
156−
157− expect(group).toMatchObject({
158− type: "activity-group",
159− });
160− if (!group || group.type !== "activity-group") {
161− return;
162− }
163−
164− expect(group.activities).toHaveLength(1);
165− expect(group.activities[0]).toMatchObject({
166− id: "tool-completed",
167− createdAt: "2026-04-01T00:00:02.000Z",
168− turnId: "turn-1",
169− summary: "Run tests",
170− detail: "bun run test",
171− canExpand: true,
172− icon: "command",
173− toolLike: true,
174− status: "success",
175− });
176− expect(group.activities[0]?.getFullDetail()).toBe("/bin/zsh -lc 'bun run test'");
177− expect(group.activities[0]?.getCopyText()).toBe(
178− "Run tests\nbun run test\n/bin/zsh -lc 'bun run test'",
179− );
180− });
181−
182− it("keeps MCP inputs available to expanded mobile work rows", () => {
183− const turnId = TurnId.make("turn-mcp");
184− const thread = makeThread({
185− id: ThreadId.make("thread-mcp"),
186− projectId: ProjectId.make("project-1"),
187− title: "Expandable MCP call",
188− latestTurn: {
189− turnId,
190− state: "completed",
191− requestedAt: "2026-04-01T00:00:00.000Z",
192− startedAt: "2026-04-01T00:00:01.000Z",
193− completedAt: "2026-04-01T00:00:03.000Z",
194− assistantMessageId: null,
195− },
196− activities: [
197− makeActivity({
198− id: EventId.make("mcp-completed"),
199− kind: "tool.completed",
200− tone: "tool",
201− summary: "Call repository tool",
202− createdAt: "2026-04-01T00:00:02.000Z",
203− turnId,
204− payload: {
205− title: "Call repository tool",
206− itemType: "mcp_tool_call",
207− detail: "repository.search",
208− status: "completed",
209− data: {
210− item: {
211− server: "repository",
212− tool: "search",
213− arguments: { query: "work log" },
214− },
215− },
216− },
217− }),
218− ],
219− });
220−
221− const group = buildThreadFeed(thread)[0];
222− expect(group).toMatchObject({ type: "activity-group" });
223− if (!group || group.type !== "activity-group") {
224− return;
225− }
226−
227− expect(group.activities[0]?.icon).toBe("wrench");
228− expect(group.activities[0]?.getFullDetail()).toContain('"query": "work log"');
229− expect(group.activities[0]?.getFullDetail()).toContain("repository.search");
55+ expect(feed.map((entry) => entry.type)).toEqual(["message", "activity-group", "message"]);
56+ const activity = feed.find((entry) => entry.type === "activity-group")?.activities[0];
57+ expect(activity?.runId).toBe(runId);
58+ expect(activity?.fullDetail).toContain('"input": "vp check"');
23059 });
23160
232− it("defers large tool output expansion until a work row is opened or copied", () => {
233− let serializedToolOutputs = 0;
234− const activities = Array.from({ length: 5_000 }, (_, index) =>
235− makeActivity({
236− id: EventId.make(`large-tool-${index}`),
237− kind: "tool.completed",
238− tone: "tool",
239− summary: `Tool ${index}`,
240− createdAt: new Date(Date.UTC(2026, 3, 1, 0, 0, index)).toISOString(),
241− payload: {
242− title: `Tool ${index}`,
243− itemType: "mcp_tool_call",
244− status: "completed",
245− data: {
246− item: {
247− toJSON: () => {
248− serializedToolOutputs += 1;
249− return { output: "x".repeat(32_768) };
250− },
251− },
252− },
253− },
254− }),
255− );
256− const thread = makeThread({
257− id: ThreadId.make("thread-large-tools"),
258− projectId: ProjectId.make("project-1"),
259− title: "Large tools",
260− activities,
261− });
262−
263− const feed = buildThreadFeed(thread);
264− expect(serializedToolOutputs).toBe(0);
265−
266− const group = feed[0];
267− expect(group).toMatchObject({ type: "activity-group" });
268− if (!group || group.type !== "activity-group") {
269− return;
270− }
271−
272− expect(group.activities).toHaveLength(5_000);
273− expect(group.activities[0]?.getFullDetail()).toContain('"output"');
274− expect(serializedToolOutputs).toBe(1);
275− expect(group.activities[0]?.getCopyText()).toContain('"output"');
276− expect(serializedToolOutputs).toBe(1);
277− });
278−
279− it("folds settled turn work while leaving the terminal answer visible", () => {
280− const turnId = TurnId.make("turn-1");
281− const thread = makeThread({
282− id: ThreadId.make("thread-3"),
283− projectId: ProjectId.make("project-1"),
284− title: "Folded work",
285− latestTurn: {
286− turnId,
287− state: "completed",
288− requestedAt: "2026-04-01T00:00:00.000Z",
289− startedAt: "2026-04-01T00:00:01.000Z",
290− completedAt: "2026-04-01T00:00:18.000Z",
291− assistantMessageId: MessageId.make("assistant-final"),
292− },
61+ it("folds settled V2 run work while keeping the terminal assistant message visible", () => {
62+ const thread = makeThreadFixture({
29363 messages: [
294− {
295− id: MessageId.make("assistant-commentary"),
296− role: "assistant",
297− text: "I am checking.",
298− turnId,
299− streaming: false,
300− createdAt: "2026-04-01T00:00:02.000Z",
301− updatedAt: "2026-04-01T00:00:03.000Z",
302− },
303− {
304− id: MessageId.make("assistant-final"),
305− role: "assistant",
306− text: "Done.",
307− turnId,
308− streaming: false,
309− createdAt: "2026-04-01T00:00:17.000Z",
310− updatedAt: "2026-04-01T00:00:18.000Z",
311− },
312− ],
313− activities: [
314− makeActivity({
315− id: EventId.make("tool-completed"),
316− kind: "tool.completed",
317− tone: "tool",
318− summary: "Read files",
319− createdAt: "2026-04-01T00:00:05.000Z",
320− turnId,
321− payload: {
322− title: "Read files",
323− itemType: "file_read",
324− status: "completed",
325− },
326− }),
64+ message("user", "Run checks", "2026-06-20T00:00:01.000Z", "message-user"),
65+ message("assistant", "Done", "2026-06-20T00:00:03.000Z", "message-assistant"),
32766 ],
67+ workEntries: [commandEntry()],
32868 });
329−
33069 const feed = buildThreadFeed(thread);
331− const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set());
332− expect(collapsed.map((entry) => entry.id)).toEqual(["turn-fold:turn-1", "assistant-final"]);
333− expect(collapsed[0]).toMatchObject({
334− type: "turn-fold",
335− label: "Worked for 17s",
336− expanded: false,
337− });
338−
339− const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId]));
340− expect(expanded.map((entry) => entry.id)).toEqual([
341− "turn-fold:turn-1",
342− "assistant-commentary",
343− "tool-completed",
344− "assistant-final",
70+ const latestRun = {
71+ runId,
72+ status: "completed" as const,
73+ startedAt: "2026-06-20T00:00:01.000Z",
74+ completedAt: "2026-06-20T00:00:03.000Z",
75+ };
76+
77+ const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set());
78+ expect(collapsed.map((entry) => entry.type)).toEqual(["message", "run-fold", "message"]);
79+
80+ const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([runId]));
81+ expect(expanded.map((entry) => entry.type)).toEqual([
82+ "message",
83+ "run-fold",
84+ "activity-group",
85+ "message",
34586 ]);
34687 });
34788
348− it("measures a steer-superseded turn from its user boundary through trailing work", () => {
349− const firstTurnId = TurnId.make("turn-1");
350− const secondTurnId = TurnId.make("turn-2");
351− const thread = makeThread({
352− id: ThreadId.make("thread-steered"),
353− projectId: ProjectId.make("project-1"),
354− title: "Steered work",
355− latestTurn: {
356− turnId: secondTurnId,
357− state: "running",
358− requestedAt: "2026-04-01T00:00:14.000Z",
359− startedAt: "2026-04-01T00:00:14.000Z",
360− completedAt: null,
361− assistantMessageId: MessageId.make("assistant-next"),
362− },
363− messages: [
364− {
365− id: MessageId.make("user-1"),
366− role: "user",
367− text: "Do it once more.",
368− turnId: null,
369− streaming: false,
370− createdAt: "2026-04-01T00:00:00.000Z",
371− updatedAt: "2026-04-01T00:00:00.000Z",
372− },
373− {
374− id: MessageId.make("assistant-commentary"),
375− role: "assistant",
376− text: "Kicking off call 1.",
377− turnId: firstTurnId,
378− streaming: false,
379− createdAt: "2026-04-01T00:00:09.000Z",
380− updatedAt: "2026-04-01T00:00:09.000Z",
381− },
382− {
383− id: MessageId.make("user-2"),
384− role: "user",
385− text: "Actually do 15.",
386− turnId: null,
387− streaming: false,
388− createdAt: "2026-04-01T00:00:14.000Z",
389− updatedAt: "2026-04-01T00:00:14.000Z",
390− },
391− {
392− id: MessageId.make("assistant-next"),
393− role: "assistant",
394− text: "One down - adjusting.",
395− turnId: secondTurnId,
396− streaming: true,
397− createdAt: "2026-04-01T00:00:17.000Z",
398− updatedAt: "2026-04-01T00:00:17.000Z",
399− },
400− ],
401− activities: [
402− makeActivity({
403− id: EventId.make("work-1"),
404− kind: "tool.completed",
405− tone: "tool",
406− summary: "Ran command",
407− createdAt: "2026-04-01T00:00:12.000Z",
408− turnId: firstTurnId,
409− payload: {
410− title: "Ran command",
411− itemType: "command_execution",
412− status: "completed",
413− },
414− }),
415− ],
89+ it("keeps an active run expanded and marks failed tools as failures", () => {
90+ const thread = makeThreadFixture({
91+ messages: [message("user", "Run checks", "2026-06-20T00:00:01.000Z", "message-user")],
92+ workEntries: [commandEntry({ tone: "error", toolLifecycleStatus: "failed" })],
41693 });
417−
41894 const feed = buildThreadFeed(thread);
419− const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set());
420− expect(collapsed.find((entry) => entry.type === "turn-fold")).toMatchObject({
421− turnId: firstTurnId,
422− label: "Worked for 12s",
423− });
424− });
425−
426− it("keeps an active turn expanded and classifies error-shaped tool output", () => {
427− const turnId = TurnId.make("turn-running");
428− const thread = makeThread({
429− id: ThreadId.make("thread-4"),
430− projectId: ProjectId.make("project-1"),
431− title: "Running work",
432− latestTurn: {
433− turnId,
434− state: "running",
435− requestedAt: "2026-04-01T00:00:00.000Z",
436− startedAt: "2026-04-01T00:00:01.000Z",
95+ const presented = deriveThreadFeedPresentation(
96+ feed,
97+ {
98+ runId,
99+ status: "running",
100+ startedAt: "2026-06-20T00:00:01.000Z",
437101 completedAt: null,
438− assistantMessageId: null,
439102 },
440− activities: [
441− makeActivity({
442− id: EventId.make("tool-failed"),
443− kind: "tool.completed",
444− tone: "tool",
445− summary: "Run command",
446− createdAt: "2026-04-01T00:00:05.000Z",
447− turnId,
448− payload: {
449− title: "Run command",
450− itemType: "command_execution",
451− detail: "zsh: command not found: nope",
452− status: "completed",
453− },
454− }),
455− ],
456− });
103+ new Set(),
104+ );
457105
458− const feed = buildThreadFeed(thread);
459− expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toEqual(feed);
460− expect(feed[0]).toMatchObject({
461− type: "activity-group",
462− activities: [{ status: "failure" }],
463− });
106+ expect(presented.some((entry) => entry.type === "run-fold")).toBe(false);
107+ expect(presented.find((entry) => entry.type === "activity-group")?.activities[0]?.status).toBe(
108+ "failure",
109+ );
464110 });
465111
466112 it("appends active work as a normal timeline row", () => {
apps/mobile/src/lib/threadActivity.ts+214−1186
@@ -1,29 +1,17 @@
1−import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts";
21 import type {
3− OrchestrationLatestTurn,
4− OrchestrationThread,
5− OrchestrationThreadActivity,
6− ToolLifecycleItemType,
7− TurnId,
8− UserInputQuestion,
9−} from "@t3tools/contracts";
2+ EnvironmentThread,
3+ ThreadConversationMessage,
4+ ThreadPendingApproval,
5+ ThreadPendingUserInput,
6+ ThreadRunSummary,
7+ ThreadUserInputQuestion,
8+ ThreadWorkEntry,
9+} from "@t3tools/client-runtime/state/shell";
10+import type { RunId } from "@t3tools/contracts";
1011 import { formatDuration } from "@t3tools/shared/orchestrationTiming";
1112
12−import * as Arr from "effect/Array";
13−import * as Order from "effect/Order";
14−
15−export interface PendingApproval {
16− readonly requestId: ApprovalRequestId;
17− readonly requestKind: "command" | "file-read" | "file-change";
18− readonly createdAt: string;
19− readonly detail?: string;
20−}
21−
22−export interface PendingUserInput {
23− readonly requestId: ApprovalRequestId;
24− readonly createdAt: string;
25− readonly questions: ReadonlyArray<UserInputQuestion>;
26−}
13+export type PendingApproval = ThreadPendingApproval;
14+export type PendingUserInput = ThreadPendingUserInput;
2715
2816 export interface PendingUserInputDraftAnswer {
2917 readonly selectedOptionLabel?: string;
@@ -33,7 +21,7 @@ export interface PendingUserInputDraftAnswer { · 3 unmodified lines
3321 export interface ThreadFeedActivity {
3422 readonly id: string;
3523 readonly createdAt: string;
36− readonly turnId: TurnId | null;
24+ readonly runId: RunId | null;
3725 readonly summary: string;
3826 readonly detail: string | null;
3927 readonly canExpand: boolean;
@@ -56,44 +44,18 @@ export interface ThreadFeedActivity { · 16 unmodified lines
5644 readonly status: "success" | "failure" | "neutral" | null;
5745 }
5846
59−const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
60−
61−type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped";
62−
63−interface WorkLogEntry {
64− id: string;
65− createdAt: string;
66− turnId: TurnId | null;
67− label: string;
68− detail?: string;
69− command?: string;
70− rawCommand?: string;
71− changedFiles?: ReadonlyArray<string>;
72− tone: "thinking" | "tool" | "info" | "error";
73− toolTitle?: string;
74− itemType?: ToolLifecycleItemType;
75− requestKind?: PendingApproval["requestKind"];
76− toolLifecycleStatus?: WorkLogToolLifecycleStatus;
77− toolData?: unknown;
78−}
79−
80−interface DerivedWorkLogEntry extends WorkLogEntry {
81− activityKind: OrchestrationThreadActivity["kind"];
82− collapseKey?: string;
83−}
84−
8547 type RawThreadFeedEntry =
8648 | {
8749 readonly type: "message";
8850 readonly id: string;
8951 readonly createdAt: string;
90− readonly message: OrchestrationThread["messages"][number];
52+ readonly message: ThreadConversationMessage;
9153 }
9254 | {
9355 readonly type: "activity";
9456 readonly id: string;
9557 readonly createdAt: string;
96− readonly turnId: TurnId | null;
58+ readonly runId: RunId | null;
9759 readonly activity: ThreadFeedActivity;
9860 };
9961
@@ -108,119 +70,25 @@ export type ThreadFeedEntry = · 8 unmodified lines
10870 readonly type: "activity-group";
10971 readonly id: string;
11072 readonly createdAt: string;
111− readonly turnId: TurnId | null;
73+ readonly runId: RunId | null;
11274 readonly activities: ReadonlyArray<ThreadFeedActivity>;
11375 }
11476 | {
115− readonly type: "work-toggle";
116− readonly id: string;
117− readonly createdAt: string;
118− readonly turnId: TurnId | null;
119− readonly groupId: string;
120− readonly hiddenCount: number;
121− readonly expanded: boolean;
122− readonly onlyToolActivities: boolean;
123− }
124− | {
125− readonly type: "turn-fold";
77+ readonly type: "run-fold";
12678 readonly id: string;
12779 readonly createdAt: string;
128− readonly turnId: TurnId;
80+ readonly runId: RunId;
12981 readonly label: string;
13082 readonly expanded: boolean;
13183 };
13284
133−export type ThreadFeedLatestTurn = Pick<
134− OrchestrationLatestTurn,
135− "turnId" | "state" | "startedAt" | "completedAt"
85+export type ThreadFeedLatestRun = Pick<
86+ ThreadRunSummary,
87+ "runId" | "status" | "startedAt" | "completedAt"
13688 >;
13789
138−function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null {
139− switch (requestType) {
140− case "command_execution_approval":
141− case "exec_command_approval":
142− return "command";
143− case "file_read_approval":
144− return "file-read";
145− case "file_change_approval":
146− case "apply_patch_approval":
147− return "file-change";
148− default:
149− return null;
150− }
151−}
152−
153−function isStalePendingRequestFailureDetail(detail: string | undefined): boolean {
154− const normalized = detail?.toLowerCase();
155− if (!normalized) {
156− return false;
157− }
158− return (
159− normalized.includes("stale pending approval request") ||
160− normalized.includes("stale pending user-input request") ||
161− normalized.includes("unknown pending approval request") ||
162− normalized.includes("unknown pending permission request") ||
163− normalized.includes("unknown pending user-input request")
164− );
165−}
166−
167−function parseApprovalRequestId(value: unknown): ApprovalRequestId | null {
168− return typeof value === "string" && value.length > 0 ? ApprovalRequestId.make(value) : null;
169−}
170−
171−function parseUserInputQuestions(
172− payload: Record<string, unknown> | null,
173−): ReadonlyArray<UserInputQuestion> | null {
174− const questions = payload?.questions;
175− if (!Array.isArray(questions)) {
176− return null;
177− }
178−
179− const parsed = questions
180− .map<UserInputQuestion | null>((entry) => {
181− if (!entry || typeof entry !== "object") return null;
182− const question = entry as Record<string, unknown>;
183− if (
184− typeof question.id !== "string" ||
185− typeof question.header !== "string" ||
186− typeof question.question !== "string" ||
187− !Array.isArray(question.options)
188− ) {
189− return null;
190− }
191− const options = question.options
192− .map<UserInputQuestion["options"][number] | null>((option) => {
193− if (!option || typeof option !== "object") return null;
194− const record = option as Record<string, unknown>;
195− if (typeof record.label !== "string" || typeof record.description !== "string") {
196− return null;
197− }
198− return {
199− label: record.label,
200− description: record.description,
201− };
202− })
203− .filter((option): option is UserInputQuestion["options"][number] => option !== null);
204− if (options.length === 0) {
205− return null;
206− }
207− return {
208− id: question.id,
209− header: question.header,
210− question: question.question,
211− options,
212− multiSelect: question.multiSelect === true,
213− };
214− })
215− .filter((question): question is UserInputQuestion => question !== null);
216−
217− return parsed.length > 0 ? parsed : null;
218−}
219−
22090 function normalizeDraftAnswer(value: string | undefined): string | null {
221− if (typeof value !== "string") {
222− return null;
223− }
91+ if (typeof value !== "string") return null;
22492 const trimmed = value.trim();
22593 return trimmed.length > 0 ? trimmed : null;
22694 }
@@ -228,882 +96,247 @@ function normalizeDraftAnswer(value: string | undefined): string | null { · 1 unmodified line
22896 function resolvePendingUserInputAnswer(
22997 draft: PendingUserInputDraftAnswer | undefined,
23098 ): string | null {
231− const customAnswer = normalizeDraftAnswer(draft?.customAnswer);
232− if (customAnswer) {
233− return customAnswer;
234− }
235− return normalizeDraftAnswer(draft?.selectedOptionLabel);
236−}
237−
238−function deriveWorkLogEntries(
239− activities: ReadonlyArray<OrchestrationThreadActivity>,
240−): DerivedWorkLogEntry[] {
241− const ordered = Arr.sort(activities, activityOrder);
242− const entries: DerivedWorkLogEntry[] = [];
243− for (const activity of ordered) {
244− if (activity.kind === "tool.started") continue;
245− if (activity.kind === "task.started") continue;
246− if (activity.kind === "context-window.updated") continue;
247− if (activity.summary === "Checkpoint captured") continue;
248− if (isPlanBoundaryToolActivity(activity)) continue;
249− entries.push(toDerivedWorkLogEntry(activity));
250− }
251− return collapseDerivedWorkLogEntries(entries);
252−}
253−
254−function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
255− if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {
256− return false;
257− }
258−
259− const payload =
260− activity.payload && typeof activity.payload === "object"
261− ? (activity.payload as Record<string, unknown>)
262− : null;
263− return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");
264−}
265−
266−function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry {
267− const payload =
268− activity.payload && typeof activity.payload === "object"
269− ? (activity.payload as Record<string, unknown>)
270− : null;
271− const commandPreview = extractToolCommand(payload);
272− const changedFiles = extractChangedFiles(payload);
273− const title = extractToolTitle(payload);
274− const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed";
275− const taskSummary =
276− isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0
277− ? payload.summary
278− : null;
279− const taskDetailAsLabel =
280− isTaskActivity &&
281− !taskSummary &&
282− typeof payload?.detail === "string" &&
283− payload.detail.length > 0
284− ? payload.detail
285− : null;
286− const taskLabel = taskSummary || taskDetailAsLabel;
287− const entry: DerivedWorkLogEntry = {
288− id: activity.id,
289− createdAt: activity.createdAt,
290− turnId: activity.turnId,
291− label: taskLabel || activity.summary,
292− tone:
293− activity.kind === "task.progress"
294− ? "thinking"
295− : activity.tone === "approval"
296− ? "info"
297− : activity.tone,
298− activityKind: activity.kind,
299− };
300− const itemType = extractWorkLogItemType(payload);
301− const requestKind = extractWorkLogRequestKind(payload);
302− if (
303− !taskDetailAsLabel &&
304− payload &&
305− typeof payload.detail === "string" &&
306− payload.detail.length > 0
307− ) {
308− const detail = stripTrailingExitCode(payload.detail).output;
309− if (detail) {
310− entry.detail = detail;
311− }
312− }
313− if (commandPreview.command) {
314− entry.command = commandPreview.command;
315− }
316− if (commandPreview.rawCommand) {
317− entry.rawCommand = commandPreview.rawCommand;
318− }
319− if (changedFiles.length > 0) {
320− entry.changedFiles = changedFiles;
321− }
322− if (title) {
323− entry.toolTitle = title;
324− }
325− if (itemType === "mcp_tool_call") {
326− const data = asRecord(payload?.data);
327− if (data?.item !== undefined) {
328− entry.toolData = data.item;
329− }
330− }
331− if (itemType) {
332− entry.itemType = itemType;
333− }
334− if (requestKind) {
335− entry.requestKind = requestKind;
336− }
337− let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload);
338− if (!toolLifecycleStatus && activity.kind === "tool.completed") {
339− toolLifecycleStatus = "completed";
340− }
341− if (toolLifecycleStatus) {
342− entry.toolLifecycleStatus = toolLifecycleStatus;
343− }
344− const collapseKey = deriveToolLifecycleCollapseKey(entry);
345− if (collapseKey) {
346− entry.collapseKey = collapseKey;
347− }
348− return entry;
349−}
350−
351−function collapseDerivedWorkLogEntries(
352− entries: ReadonlyArray<DerivedWorkLogEntry>,
353−): DerivedWorkLogEntry[] {
354− const collapsed: DerivedWorkLogEntry[] = [];
355− for (const entry of entries) {
356− const previous = collapsed.at(-1);
357− if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {
358− collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry);
359− continue;
360− }
361− collapsed.push(entry);
362− }
363− return collapsed;
364−}
365−
366−function shouldCollapseToolLifecycleEntries(
367− previous: DerivedWorkLogEntry,
368− next: DerivedWorkLogEntry,
369−): boolean {
370− if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") {
371− return false;
372− }
373− if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") {
374− return false;
375− }
376− if (previous.activityKind === "tool.completed") {
377− return false;
378− }
379− return previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey;
380−}
381−
382−function mergeDerivedWorkLogEntries(
383− previous: DerivedWorkLogEntry,
384− next: DerivedWorkLogEntry,
385−): DerivedWorkLogEntry {
386− const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles);
387− const detail = next.detail ?? previous.detail;
388− const command = next.command ?? previous.command;
389− const rawCommand = next.rawCommand ?? previous.rawCommand;
390− const toolTitle = next.toolTitle ?? previous.toolTitle;
391− const itemType = next.itemType ?? previous.itemType;
392− const requestKind = next.requestKind ?? previous.requestKind;
393− const collapseKey = next.collapseKey ?? previous.collapseKey;
394− const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus;
395− const toolData = next.toolData ?? previous.toolData;
396− return {
397− ...previous,
398− ...next,
399− ...(detail ? { detail } : {}),
400− ...(command ? { command } : {}),
401− ...(rawCommand ? { rawCommand } : {}),
402− ...(changedFiles.length > 0 ? { changedFiles } : {}),
403− ...(toolTitle ? { toolTitle } : {}),
404− ...(itemType ? { itemType } : {}),
405− ...(requestKind ? { requestKind } : {}),
406− ...(collapseKey ? { collapseKey } : {}),
407− ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}),
408− ...(toolData !== undefined ? { toolData } : {}),
409− };
410−}
411−
412−function mergeChangedFiles(
413− previous: ReadonlyArray<string> | undefined,
414− next: ReadonlyArray<string> | undefined,
415−): string[] {
416− const merged = [...(previous ?? []), ...(next ?? [])];
417− if (merged.length === 0) {
418− return [];
419− }
420− return [...new Set(merged)];
421−}
422−
423−function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | undefined {
424− if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") {
425− return undefined;
426− }
427− const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label);
428− const detail = entry.detail?.trim() ?? "";
429− const itemType = entry.itemType ?? "";
430− if (normalizedLabel.length === 0 && detail.length === 0 && itemType.length === 0) {
431− return undefined;
432− }
433− return [itemType, normalizedLabel, detail].join("\u001f");
434−}
435−
436−function normalizeCompactToolLabel(value: string): string {
437− return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
438−}
439−
440−function workLogEntryIsToolLike(entry: WorkLogEntry): boolean {
441− if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") {
442− return true;
443− }
444− if (entry.command !== undefined && entry.command.trim().length > 0) {
445− return true;
446− }
447− if (entry.requestKind !== undefined) {
448− return true;
449− }
450− return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType);
451−}
452−
453−function toolDetailTextLooksLikeFailure(text: string): boolean {
454− const normalized = text.toLowerCase();
45599 return (
456− normalized.includes("file not found") ||
457− normalized.includes("no files found") ||
458− normalized.includes("enoent") ||
459− normalized.includes("no such file or directory") ||
460− normalized.includes("no such file") ||
461− normalized.includes("commandnotfoundexception") ||
462− normalized.includes("command not found") ||
463− (normalized.includes("cannot find path") && normalized.includes("because it does not exist")) ||
464− (normalized.includes("is not recognized") && normalized.includes("the term '")) ||
465− /<exited with exit code\s+[1-9]\d*\s*>/i.test(text) ||
466− /exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text) ||
467− /exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text)
100+ normalizeDraftAnswer(draft?.customAnswer) ?? normalizeDraftAnswer(draft?.selectedOptionLabel)
468101 );
469102 }
470103
471−function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean {
472− if (entry.tone === "error") {
473− return true;
474− }
475− if (entry.toolLifecycleStatus === "failed" || entry.toolLifecycleStatus === "declined") {
476− return true;
477− }
478− if (!workLogEntryIsToolLike(entry)) {
479− return false;
480− }
481− return toolDetailTextLooksLikeFailure([entry.detail, entry.command].filter(Boolean).join("\n"));
104+function capitalizePhrase(value: string): string {
105+ const trimmed = value.trim();
106+ return trimmed.length === 0 ? value : `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`;
482107 }
483108
484−function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean {
485− if (!workLogEntryIsToolLike(entry) || workEntryIndicatesToolFailure(entry)) {
486− return false;
487− }
488− if (entry.tone === "thinking") {
489− return false;
490− }
109+function workEntryIsToolLike(entry: ThreadWorkEntry): boolean {
491110 return (
492− entry.toolLifecycleStatus !== "inProgress" &&
493− entry.toolLifecycleStatus !== "stopped" &&
494− entry.toolLifecycleStatus !== "failed" &&
495− entry.toolLifecycleStatus !== "declined"
111+ entry.tone === "tool" ||
112+ entry.tone === "thinking" ||
113+ entry.tone === "error" ||
114+ entry.command !== undefined ||
115+ entry.requestKind !== undefined
496116 );
497117 }
498118
499−function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] {
500− if (!workLogEntryIsToolLike(entry)) {
501− return null;
502− }
503− if (workEntryIndicatesToolFailure(entry)) {
504− return "failure";
505− }
506− if (workEntryIndicatesToolSuccess(entry)) {
507− return "success";
508− }
509− return "neutral";
510−}
511−
512−function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] {
119+function workEntryStatus(entry: ThreadWorkEntry): ThreadFeedActivity["status"] {
120+ if (!workEntryIsToolLike(entry)) return null;
513121 if (
514− entry.activityKind === "user-input.requested" ||
515− entry.activityKind === "user-input.resolved"
122+ entry.tone === "error" ||
123+ entry.toolLifecycleStatus === "failed" ||
124+ entry.toolLifecycleStatus === "declined"
516125 ) {
517− return "message";
518− }
519− if (entry.activityKind === "runtime.warning") return "warning";
520− if (entry.requestKind === "command") return "command";
521− if (entry.requestKind === "file-read") return "eye";
522− if (entry.requestKind === "file-change") return "edit";
523− if (entry.itemType === "command_execution" || entry.command) return "command";
524− if (entry.itemType === "file_change" || (entry.changedFiles?.length ?? 0) > 0) return "edit";
525− if (entry.itemType === "web_search") return "globe";
526− if (entry.itemType === "image_view") return "eye";
527− if (entry.itemType === "mcp_tool_call") return "wrench";
528− if (entry.itemType === "dynamic_tool_call" || entry.itemType === "collab_agent_tool_call") {
529− return "hammer";
126+ return "failure";
530127 }
531− if (entry.tone === "error") return "alert";
532− if (entry.tone === "thinking") return "agent";
533− if (entry.tone === "info") return "check";
534− return "zap";
128+ return entry.toolLifecycleStatus === "completed" ? "success" : "neutral";
535129 }
536130
537−function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null {
538− const blocks: string[] = [];
539− const appendUniqueBlock = (value: string | null | undefined) => {
540− const trimmed = value?.trim();
541− if (trimmed && !blocks.includes(trimmed)) {
542− blocks.push(trimmed);
543− }
544− };
545−
546− if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) {
547− appendUniqueBlock(`MCP call\n${JSON.stringify(entry.toolData, null, 2)}`);
548− }
549− appendUniqueBlock(entry.rawCommand ?? entry.command);
550− appendUniqueBlock(entry.detail);
551− if ((entry.changedFiles?.length ?? 0) > 0) {
552− appendUniqueBlock(entry.changedFiles!.join("\n"));
131+function workEntryIcon(entry: ThreadWorkEntry): ThreadFeedActivity["icon"] {
132+ switch (entry.itemType) {
133+ case "reasoning":
134+ return "agent";
135+ case "command_execution":
136+ return "command";
137+ case "file_change":
138+ return "edit";
139+ case "file_search":
140+ return "eye";
141+ case "web_search":
142+ return "globe";
143+ case "approval_request":
144+ case "user_input_request":
145+ return "message";
146+ case "dynamic_tool":
147+ return "wrench";
148+ case "subagent":
149+ return "hammer";
150+ case "run_interrupt_request":
151+ case "run_interrupt_result":
152+ return "warning";
153+ case "checkpoint":
154+ return "check";
155+ default:
156+ if (entry.tone === "error") return "alert";
157+ if (entry.tone === "thinking") return "agent";
158+ if (entry.tone === "info") return "check";
159+ return "zap";
553160 }
554−
555− return blocks.length > 0 ? blocks.join("\n\n") : null;
556−}
557−
558−function workEntryHasExpandedBody(entry: WorkLogEntry): boolean {
559− return (
560− (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) ||
561− Boolean((entry.rawCommand ?? entry.command)?.trim()) ||
562− Boolean(entry.detail?.trim()) ||
563− (entry.changedFiles?.some((path) => path.trim().length > 0) ?? false)
564− );
565−}
566−
567−function memoizeValue<T>(build: () => T): () => T {
568− let value: T;
569− let initialized = false;
570− return () => {
571− if (!initialized) {
572− value = build();
573− initialized = true;
574− }
575− return value;
576− };
577161 }
578162
579−function workEntryPreview(
580− workEntry: Pick<WorkLogEntry, "detail" | "command" | "changedFiles">,
581−): string | null {
582− if (workEntry.command) return workEntry.command;
583− if (workEntry.detail) return workEntry.detail;
584− if ((workEntry.changedFiles?.length ?? 0) === 0) return null;
585− const [firstPath] = workEntry.changedFiles ?? [];
163+function workEntryPreview(entry: ThreadWorkEntry): string | null {
164+ if (entry.command) return entry.command;
165+ if (entry.detail) return entry.detail;
166+ const firstPath = entry.changedFiles?.[0];
586167 if (!firstPath) return null;
587− return workEntry.changedFiles!.length === 1
168+ return entry.changedFiles?.length === 1
588169 ? firstPath
589− : `${firstPath} +${workEntry.changedFiles!.length - 1} more`;
590−}
591−
592−function capitalizePhrase(value: string): string {
593− const trimmed = value.trim();
594− if (trimmed.length === 0) {
595− return value;
596− }
597− return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`;
598−}
599−
600−function workEntryHeading(workEntry: WorkLogEntry): string {
601− if (!workEntry.toolTitle) {
602− return capitalizePhrase(normalizeCompactToolLabel(workEntry.label));
603− }
604− return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle));
605−}
606−
607−function asRecord(value: unknown): Record<string, unknown> | null {
608− return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
609−}
610−
611−function asTrimmedString(value: unknown): string | null {
612− if (typeof value !== "string") {
613− return null;
614− }
615− const trimmed = value.trim();
616− return trimmed.length > 0 ? trimmed : null;
617−}
618−
619−function trimMatchingOuterQuotes(value: string): string {
620− const trimmed = value.trim();
621− if (
622− (trimmed.startsWith("'") && trimmed.endsWith("'")) ||
623− (trimmed.startsWith('"') && trimmed.endsWith('"'))
624− ) {
625− const unquoted = trimmed.slice(1, -1).trim();
626− return unquoted.length > 0 ? unquoted : trimmed;
627− }
628− return trimmed;
629−}
630−
631−function executableBasename(value: string): string | null {
632− const trimmed = trimMatchingOuterQuotes(value);
633− if (trimmed.length === 0) {
634− return null;
635− }
636− const normalized = trimmed.replace(/\\/g, "/");
637− const segments = normalized.split("/");
638− const last = segments.at(-1)?.trim() ?? "";
639− return last.length > 0 ? last.toLowerCase() : null;
640−}
641−
642−function splitExecutableAndRest(value: string): { executable: string; rest: string } | null {
643− const trimmed = value.trim();
644− if (trimmed.length === 0) {
645− return null;
646− }
647−
648− if (trimmed.startsWith('"') || trimmed.startsWith("'")) {
649− const quote = trimmed.charAt(0);
650− const closeIndex = trimmed.indexOf(quote, 1);
651− if (closeIndex <= 0) {
652− return null;
653− }
654− return {
655− executable: trimmed.slice(0, closeIndex + 1),
656− rest: trimmed.slice(closeIndex + 1).trim(),
657− };
658− }
659−
660− const firstWhitespace = trimmed.search(/\s/);
661− if (firstWhitespace < 0) {
662− return {
663− executable: trimmed,
664− rest: "",
665− };
666− }
667−
668− return {
669− executable: trimmed.slice(0, firstWhitespace),
670− rest: trimmed.slice(firstWhitespace).trim(),
671− };
672−}
673−
674−const SHELL_WRAPPER_SPECS = [
675− {
676− executables: ["pwsh", "pwsh.exe", "powershell", "powershell.exe"],
677− wrapperFlagPattern: /(?:^|\s)-command\s+/i,
678− },
679− {
680− executables: ["cmd", "cmd.exe"],
681− wrapperFlagPattern: /(?:^|\s)\/c\s+/i,
682− },
683− {
684− executables: ["bash", "sh", "zsh"],
685− wrapperFlagPattern: /(?:^|\s)-(?:l)?c\s+/i,
686− },
687−] as const;
688−
689−function findShellWrapperSpec(shell: string) {
690− return SHELL_WRAPPER_SPECS.find((spec) =>
691− (spec.executables as ReadonlyArray<string>).includes(shell),
692− );
170+ : `${firstPath} +${(entry.changedFiles?.length ?? 1) - 1} more`;
693171 }
694172
695−function unwrapCommandRemainder(value: string, wrapperFlagPattern: RegExp): string | null {
696− const match = wrapperFlagPattern.exec(value);
697− if (!match) {
698− return null;
699− }
700−
701− const command = value.slice(match.index + match[0].length).trim();
702− if (command.length === 0) {
703− return null;
704− }
705−
706− const unwrapped = trimMatchingOuterQuotes(command);
707− return unwrapped.length > 0 ? unwrapped : null;
708−}
709−
710−function unwrapKnownShellCommandWrapper(value: string): string {
711− const split = splitExecutableAndRest(value);
712− if (!split || split.rest.length === 0) {
713− return value;
714− }
715−
716− const shell = executableBasename(split.executable);
717− if (!shell) {
718− return value;
719− }
720−
721− const spec = findShellWrapperSpec(shell);
722− if (!spec) {
723− return value;
724− }
725−
726− return unwrapCommandRemainder(split.rest, spec.wrapperFlagPattern) ?? value;
727−}
728−
729−function formatCommandArrayPart(value: string): string {
730− return /[\s"'`]/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value;
731−}
732−
733−function formatCommandValue(value: unknown): string | null {
734− const direct = asTrimmedString(value);
735− if (direct) {
736− return direct;
737− }
738− if (!Array.isArray(value)) {
739− return null;
740− }
741− const parts: Array<string> = [];
742− for (const entry of value) {
743− const part = asTrimmedString(entry);
744− if (part !== null) {
745− parts.push(part);
746− }
747− }
748− if (parts.length === 0) {
749− return null;
750− }
751− return parts.map((part) => formatCommandArrayPart(part)).join(" ");
752−}
753−
754−function normalizeCommandValue(value: unknown): string | null {
755− const formatted = formatCommandValue(value);
756− return formatted ? unwrapKnownShellCommandWrapper(formatted) : null;
757−}
758−
759−function toRawToolCommand(value: unknown, normalizedCommand: string | null): string | null {
760− const formatted = formatCommandValue(value);
761− if (!formatted || normalizedCommand === null) {
762− return null;
763− }
764− return formatted === normalizedCommand ? null : formatted;
765−}
766−
767−function extractToolCommand(payload: Record<string, unknown> | null): {
768− command: string | null;
769− rawCommand: string | null;
770−} {
771− const data = asRecord(payload?.data);
772− const item = asRecord(data?.item);
773− const itemResult = asRecord(item?.result);
774− const itemInput = asRecord(item?.input);
775− const itemType = asTrimmedString(payload?.itemType);
776− const detail = asTrimmedString(payload?.detail);
777− const candidates: unknown[] = [
778− item?.command,
779− itemInput?.command,
780− itemResult?.command,
781− data?.command,
782− itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null,
783− ];
784−
785− for (const candidate of candidates) {
786− const command = normalizeCommandValue(candidate);
787− if (!command) {
788− continue;
789− }
790− return {
791− command,
792− rawCommand: toRawToolCommand(candidate, command),
793− };
794− }
795−
796− return {
797− command: null,
798− rawCommand: null,
173+function buildWorkEntryExpandedBody(entry: ThreadWorkEntry): string | null {
174+ const blocks: string[] = [];
175+ const append = (value: string | null | undefined) => {
176+ const trimmed = value?.trim();
177+ if (trimmed && !blocks.includes(trimmed)) blocks.push(trimmed);
799178 };
179+ append(entry.rawCommand ?? entry.command);
180+ append(entry.detail);
181+ if (entry.changedFiles?.length) append(entry.changedFiles.join("\n"));
182+ append(JSON.stringify(entry.structuredPayload, null, 2));
183+ return blocks.length === 0 ? null : blocks.join("\n\n");
800184 }
801185
802−function extractToolTitle(payload: Record<string, unknown> | null): string | null {
803− return asTrimmedString(payload?.title);
804−}
805−
806−function extractWorkLogToolLifecycleStatus(
807− payload: Record<string, unknown> | null,
808−): WorkLogToolLifecycleStatus | undefined {
809− const status = payload?.status;
810− if (
811− status === "inProgress" ||
812− status === "completed" ||
813− status === "failed" ||
814− status === "declined" ||
815− status === "stopped"
816− ) {
817− return status;
818− }
819− return undefined;
820−}
821−
822−function stripTrailingExitCode(value: string): {
823− output: string | null;
824− exitCode?: number | undefined;
825−} {
826− const trimmed = value.trim();
827− const match = /^(?<output>[\s\S]*?)(?:\s*<exited with exit code (?<code>\d+)>)\s*$/i.exec(
828− trimmed,
829− );
830− if (!match?.groups) {
831− return {
832− output: trimmed.length > 0 ? trimmed : null,
833− };
834− }
835− const exitCode = Number.parseInt(match.groups.code ?? "", 10);
836− const normalizedOutput = match.groups.output?.trim() ?? "";
186+function toFeedActivity(entry: ThreadWorkEntry): ThreadFeedActivity {
187+ const summary = capitalizePhrase(entry.toolTitle ?? entry.label);
188+ const detail = workEntryPreview(entry);
189+ const fullDetail = buildWorkEntryExpandedBody(entry);
837190 return {
838− output: normalizedOutput.length > 0 ? normalizedOutput : null,
839− ...(Number.isInteger(exitCode) ? { exitCode } : {}),
191+ id: entry.id,
192+ createdAt: entry.createdAt,
193+ runId: entry.runId,
194+ summary,
195+ detail,
196+ fullDetail,
197+ icon: workEntryIcon(entry),
198+ copyText: [summary, detail, fullDetail]
199+ .filter(
200+ (value, index, values): value is string =>
201+ Boolean(value) && values.indexOf(value) === index,
202+ )
203+ .join("\n"),
204+ toolLike: workEntryIsToolLike(entry),
205+ status: workEntryStatus(entry),
840206 };
841207 }
842208
843−function extractWorkLogItemType(
844− payload: Record<string, unknown> | null,
845−): WorkLogEntry["itemType"] | undefined {
846− if (typeof payload?.itemType === "string" && isToolLifecycleItemType(payload.itemType)) {
847− return payload.itemType;
848− }
849− return undefined;
850−}
851−
852−function extractWorkLogRequestKind(
853− payload: Record<string, unknown> | null,
854−): WorkLogEntry["requestKind"] | undefined {
855− if (
856− payload?.requestKind === "command" ||
857− payload?.requestKind === "file-read" ||
858− payload?.requestKind === "file-change"
859− ) {
860− return payload.requestKind;
861− }
862− return requestKindFromRequestType(payload?.requestType) ?? undefined;
863−}
864−
865−function pushChangedFile(target: string[], seen: Set<string>, value: unknown) {
866− const normalized = asTrimmedString(value);
867− if (!normalized || seen.has(normalized)) {
868− return;
869− }
870− seen.add(normalized);
871− target.push(normalized);
209+function byCreatedAt<A extends { readonly createdAt: string }>(left: A, right: A): number {
210+ return left.createdAt.localeCompare(right.createdAt);
872211 }
873212
874−function collectChangedFiles(value: unknown, target: string[], seen: Set<string>, depth: number) {
875− if (depth > 4 || target.length >= 12) {
876− return;
877− }
878− if (Array.isArray(value)) {
879− for (const entry of value) {
880− collectChangedFiles(entry, target, seen, depth + 1);
881− if (target.length >= 12) {
882− return;
883− }
884− }
885− return;
886− }
887−
888− const record = asRecord(value);
889− if (!record) {
890− return;
891− }
892−
893− pushChangedFile(target, seen, record.path);
894− pushChangedFile(target, seen, record.filePath);
895− pushChangedFile(target, seen, record.relativePath);
896− pushChangedFile(target, seen, record.filename);
897− pushChangedFile(target, seen, record.newPath);
898− pushChangedFile(target, seen, record.oldPath);
899−
900− for (const nestedKey of [
901− "item",
902− "result",
903− "input",
904− "data",
905− "changes",
906− "files",
907− "edits",
908− "patch",
909− "patches",
910− "operations",
911− ]) {
912− if (!(nestedKey in record)) {
913− continue;
914− }
915− collectChangedFiles(record[nestedKey], target, seen, depth + 1);
916− if (target.length >= 12) {
917− return;
918− }
919− }
920−}
921−
922−function extractChangedFiles(payload: Record<string, unknown> | null): string[] {
923− const changedFiles: string[] = [];
924− const seen = new Set<string>();
925− collectChangedFiles(asRecord(payload?.data), changedFiles, seen, 0);
926− return changedFiles;
927−}
928−
929−function compareActivityLifecycleRank(kind: string): number {
930− if (kind.endsWith(".started") || kind === "tool.started") {
931− return 0;
932− }
933− if (kind.endsWith(".progress") || kind.endsWith(".updated")) {
934− return 1;
935− }
936− if (kind.endsWith(".completed") || kind.endsWith(".resolved")) {
937− return 2;
938− }
939− return 1;
940−}
941−
942−const activityOrder = Order.combineAll<OrchestrationThreadActivity>([
943− Order.mapInput(Order.Number, (activity) => activity.sequence ?? Number.MAX_SAFE_INTEGER),
944− Order.mapInput(Order.String, (activity) => activity.createdAt),
945− Order.mapInput(Order.Number, (activity) => compareActivityLifecycleRank(activity.kind)),
946− Order.mapInput(Order.String, (activity) => activity.id),
947−]);
948−
949213 function isEmptyMessage(entry: RawThreadFeedEntry): boolean {
950− if (entry.type !== "message") {
951− return false;
952− }
953− const hasText = entry.message.text.trim().length > 0;
954− const hasAttachments = (entry.message.attachments ?? []).length > 0;
955− return !hasText && !hasAttachments;
214+ return (
215+ entry.type === "message" &&
216+ entry.message.text.trim().length === 0 &&
217+ (entry.message.attachments ?? []).length === 0
218+ );
956219 }
957220
958221 function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): ThreadFeedEntry[] {
959222 const grouped: ThreadFeedEntry[] = [];
960− // Mutable backing array for the trailing group so appending an activity is
961− // O(1) instead of re-copying the group (which made this loop quadratic on
962− // long tool runs). The array is only mutated while it is the trailing group.
963− let openGroupActivities: ThreadFeedActivity[] | null = null;
964− let openGroupTurnId: TurnId | null = null;
965−
966223 for (const entry of entries) {
967− // Skip empty messages so they don't break activity grouping.
968− if (isEmptyMessage(entry)) {
969− continue;
970− }
971−
224+ if (isEmptyMessage(entry)) continue;
972225 if (entry.type !== "activity") {
973226 grouped.push(entry);
974227 openGroupActivities = null;
975228 continue;
976229 }
977−
978− if (openGroupActivities !== null && openGroupTurnId === entry.turnId) {
979− openGroupActivities.push(entry.activity);
230+ const previous = grouped.at(-1);
231+ if (previous?.type === "activity-group" && previous.runId === entry.runId) {
232+ grouped[grouped.length - 1] = {
233+ ...previous,
234+ activities: [...previous.activities, entry.activity],
235+ };
980236 continue;
981237 }
982−
983− openGroupActivities = [entry.activity];
984− openGroupTurnId = entry.turnId;
985238 grouped.push({
986239 type: "activity-group",
987240 id: entry.id,
988241 createdAt: entry.createdAt,
989− turnId: entry.turnId,
990− activities: openGroupActivities,
242+ runId: entry.runId,
243+ activities: [entry.activity],
991244 });
992245 }
993−
994246 return grouped;
995247 }
996248
997249 function computeElapsedMs(startIso: string, endIso: string): number | null {
998250 const start = Date.parse(startIso);
999251 const end = Date.parse(endIso);
1000− if (!Number.isFinite(start) || !Number.isFinite(end)) {
1001− return null;
1002− }
1003− return Math.max(0, end - start);
252+ return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : null;
1004253 }
1005254
1006−function maxIsoTimestamp(a: string | null, b: string | null): string | null {
1007− if (a === null) return b;
1008− if (b === null) return a;
1009− const aMs = Date.parse(a);
1010− const bMs = Date.parse(b);
1011− if (!Number.isFinite(aMs)) return b;
1012− if (!Number.isFinite(bMs)) return a;
1013− return bMs > aMs ? b : a;
255+function maxIsoTimestamp(left: string | null, right: string | null): string | null {
256+ if (left === null) return right;
257+ if (right === null) return left;
258+ return Date.parse(right) > Date.parse(left) ? right : left;
1014259 }
1015260
1016−function deriveUnsettledTurnId(latestTurn: ThreadFeedLatestTurn | null): TurnId | null {
1017− if (!latestTurn) {
1018− return null;
1019− }
1020− const settled = latestTurn.completedAt !== null && latestTurn.state !== "running";
1021− return settled ? null : latestTurn.turnId;
261+function unsettledRunId(latestRun: ThreadFeedLatestRun | null): RunId | null {
262+ if (!latestRun) return null;
263+ return latestRun.completedAt === null ||
264+ latestRun.status === "starting" ||
265+ latestRun.status === "running" ||
266+ latestRun.status === "waiting"
267+ ? latestRun.runId
268+ : null;
1022269 }
1023270
1024−interface ThreadFeedTurnFold {
1025− readonly turnId: TurnId;
271+interface ThreadFeedRunFold {
272+ readonly runId: RunId;
1026273 readonly createdAt: string;
1027274 readonly hiddenEntryIds: ReadonlySet<string>;
1028275 readonly label: string;
1029276 }
1030277
1031−function deriveThreadFeedTurnFolds(
278+function deriveThreadFeedRunFolds(
1032279 feed: ReadonlyArray<ThreadFeedEntry>,
1033− latestTurn: ThreadFeedLatestTurn | null,
1034−): ReadonlyMap<string, ThreadFeedTurnFold> {
1035− const terminalAssistantMessageIdByTurn = new Map<TurnId, string>();
280+ latestRun: ThreadFeedLatestRun | null,
281+): ReadonlyMap<string, ThreadFeedRunFold> {
282+ const terminalAssistantMessageIdByRun = new Map<RunId, string>();
1036283 for (const entry of feed) {
1037− if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) {
1038− terminalAssistantMessageIdByTurn.set(entry.message.turnId, entry.id);
284+ if (entry.type === "message" && entry.message.role === "assistant" && entry.message.runId) {
285+ terminalAssistantMessageIdByRun.set(entry.message.runId, entry.id);
1039286 }
1040287 }
1041288
1042− interface TurnGroup {
1043− readonly entries: ThreadFeedEntry[];
1044− readonly startBoundary: string | null;
1045− }
1046− const groupsByTurnId = new Map<TurnId, TurnGroup>();
289+ const groupsByRunId = new Map<
290+ RunId,
291+ { entries: ThreadFeedEntry[]; startBoundary: string | null }
292+ >();
1047293 let pendingUserBoundary: string | null = null;
1048294 for (const entry of feed) {
1049295 if (entry.type === "message" && entry.message.role === "user") {
1050296 pendingUserBoundary = entry.message.createdAt;
1051297 continue;
1052298 }
1053− const turnId =
299+ const runId =
1054300 entry.type === "message" && entry.message.role === "assistant"
1055− ? entry.message.turnId
301+ ? entry.message.runId
1056302 : entry.type === "activity-group"
1057− ? entry.turnId
303+ ? entry.runId
1058304 : null;
1059− if (!turnId) {
1060− continue;
1061− }
1062− let group = groupsByTurnId.get(turnId);
305+ if (!runId) continue;
306+ let group = groupsByRunId.get(runId);
1063307 if (!group) {
1064− group = {
1065− entries: [],
1066− startBoundary: pendingUserBoundary,
1067− };
308+ group = { entries: [], startBoundary: pendingUserBoundary };
1068309 pendingUserBoundary = null;
1069− groupsByTurnId.set(turnId, group);
310+ groupsByRunId.set(runId, group);
1070311 }
1071312 group.entries.push(entry);
1072313 }
1073314
1074− const unsettledTurnId = deriveUnsettledTurnId(latestTurn);
1075− const foldsByAnchorId = new Map<string, ThreadFeedTurnFold>();
1076− for (const [turnId, group] of groupsByTurnId) {
1077− const { entries } = group;
1078− if (turnId === unsettledTurnId) {
1079− continue;
1080− }
1081− if (entries.some((entry) => entry.type === "message" && entry.message.streaming)) {
315+ const activeRunId = unsettledRunId(latestRun);
316+ const foldsByAnchorId = new Map<string, ThreadFeedRunFold>();
317+ for (const [runId, group] of groupsByRunId) {
318+ if (
319+ runId === activeRunId ||
320+ group.entries.some((entry) => entry.type === "message" && entry.message.streaming)
321+ ) {
1082322 continue;
1083323 }
1084−
1085− const terminalAssistantMessageId = terminalAssistantMessageIdByTurn.get(turnId);
324+ const terminalAssistantId = terminalAssistantMessageIdByRun.get(runId);
1086325 const hiddenEntryIds = new Set(
1087− entries.filter((entry) => entry.id !== terminalAssistantMessageId).map((entry) => entry.id),
326+ group.entries.filter((entry) => entry.id !== terminalAssistantId).map((entry) => entry.id),
1088327 );
1089− if (hiddenEntryIds.size === 0) {
1090− continue;
1091− }
1092−
1093− const firstEntry = entries[0];
1094− const lastEntry = entries.at(-1);
1095− if (!firstEntry || !lastEntry) {
1096− continue;
1097− }
1098− const terminalEntry = terminalAssistantMessageId
1099− ? entries.find((entry) => entry.id === terminalAssistantMessageId)
328+ const firstEntry = group.entries[0];
329+ const lastEntry = group.entries.at(-1);
330+ if (hiddenEntryIds.size === 0 || !firstEntry || !lastEntry) continue;
331+ const terminalEntry = terminalAssistantId
332+ ? group.entries.find((entry) => entry.id === terminalAssistantId)
1100333 : null;
1101− const latestTurnMatches = latestTurn?.turnId === turnId;
334+ const latestRunMatches = latestRun?.runId === runId;
1102335 const lastEntryEnd =
1103336 lastEntry.type === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt;
1104337 const elapsedMs =
1105− latestTurnMatches && latestTurn.startedAt && latestTurn.completedAt
1106− ? computeElapsedMs(latestTurn.startedAt, latestTurn.completedAt)
338+ latestRunMatches && latestRun.startedAt && latestRun.completedAt
339+ ? computeElapsedMs(latestRun.startedAt, latestRun.completedAt)
1107340 : computeElapsedMs(
1108341 group.startBoundary ?? firstEntry.createdAt,
1109342 maxIsoTimestamp(
@@ -1112,20 +345,19 @@ function deriveThreadFeedTurnFolds( · 2 unmodified lines
1112345 ) ?? lastEntryEnd,
1113346 );
1114347 const duration = elapsedMs === null ? null : formatDuration(elapsedMs);
1115− const interrupted = latestTurnMatches && latestTurn.state === "interrupted";
1116− const label = interrupted
1117− ? duration
1118− ? `You stopped after ${duration}`
1119− : "You stopped this response"
1120− : duration
1121− ? `Worked for ${duration}`
1122− : "Worked";
1123−
348+ const interrupted =
349+ latestRunMatches && (latestRun.status === "interrupted" || latestRun.status === "cancelled");
1124350 foldsByAnchorId.set(firstEntry.id, {
1125− turnId,
351+ runId,
1126352 createdAt: firstEntry.createdAt,
1127353 hiddenEntryIds,
1128− label,
354+ label: interrupted
355+ ? duration
356+ ? `You stopped after ${duration}`
357+ : "You stopped this response"
358+ : duration
359+ ? `Worked for ${duration}`
360+ : "Worked",
1129361 });
1130362 }
1131363 return foldsByAnchorId;
@@ -1133,41 +365,31 @@ function deriveThreadFeedTurnFolds( · 1 unmodified line
1133365
1134366 export function deriveThreadFeedPresentation(
1135367 feed: ReadonlyArray<ThreadFeedEntry>,
1136− latestTurn: ThreadFeedLatestTurn | null,
1137− expandedTurnIds: ReadonlySet<TurnId>,
1138− expandedWorkGroupIds: ReadonlySet<string> = new Set(),
1139− activeWorkStartedAt: string | null = null,
368+ latestRun: ThreadFeedLatestRun | null,
369+ expandedRunIds: ReadonlySet<RunId>,
1140370 ): ThreadFeedEntry[] {
1141− const sourceFeed = feed.filter(
1142− (entry) =>
1143− entry.type !== "turn-fold" && entry.type !== "work-toggle" && entry.type !== "working",
1144− );
1145− const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn);
371+ const sourceFeed = feed.filter((entry) => entry.type !== "run-fold");
372+ const foldsByAnchorId = deriveThreadFeedRunFolds(sourceFeed, latestRun);
1146373 const collapsedEntryIds = new Set<string>();
1147374 for (const fold of foldsByAnchorId.values()) {
1148− if (!expandedTurnIds.has(fold.turnId)) {
1149− for (const entryId of fold.hiddenEntryIds) {
1150− collapsedEntryIds.add(entryId);
1151− }
375+ if (!expandedRunIds.has(fold.runId)) {
376+ for (const entryId of fold.hiddenEntryIds) collapsedEntryIds.add(entryId);
1152377 }
1153378 }
1154−
1155379 const result: ThreadFeedEntry[] = [];
1156380 for (const entry of sourceFeed) {
1157381 const fold = foldsByAnchorId.get(entry.id);
1158382 if (fold) {
1159383 result.push({
1160− type: "turn-fold",
1161− id: `turn-fold:${fold.turnId}`,
384+ type: "run-fold",
385+ id: `run-fold:${fold.runId}`,
1162386 createdAt: fold.createdAt,
1163− turnId: fold.turnId,
387+ runId: fold.runId,
1164388 label: fold.label,
1165− expanded: expandedTurnIds.has(fold.turnId),
389+ expanded: expandedRunIds.has(fold.runId),
1166390 });
1167391 }
1168− if (!collapsedEntryIds.has(entry.id)) {
1169− appendPresentedFeedEntry(result, entry, expandedWorkGroupIds);
1170− }
392+ if (!collapsedEntryIds.has(entry.id)) result.push(entry);
1171393 }
1172394 if (activeWorkStartedAt !== null) {
1173395 result.push({
@@ -1179,248 +401,54 @@ export function deriveThreadFeedPresentation( · 5 unmodified lines
1179401 return result;
1180402 }
1181403
1182−function appendPresentedFeedEntry(
1183− result: ThreadFeedEntry[],
1184− entry: Exclude<ThreadFeedEntry, { readonly type: "turn-fold" | "work-toggle" | "working" }>,
1185− expandedWorkGroupIds: ReadonlySet<string>,
1186−): void {
1187− if (entry.type !== "activity-group") {
1188− result.push(entry);
1189− return;
1190− }
1191−
1192− const activities = entry.activities.filter(
1193− (activity) => !(activity.toolLike && activity.status === "neutral"),
1194− );
1195− if (activities.length === 0) {
1196− return;
1197− }
1198− if (activities.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) {
1199− result.push({
1200− ...entry,
1201− activities,
1202− });
1203− return;
1204− }
1205−
1206− const groupId = entry.id;
1207− const expanded = expandedWorkGroupIds.has(groupId);
1208− const hiddenCount = activities.length - MAX_VISIBLE_WORK_LOG_ENTRIES;
1209− const visibleActivities = expanded ? activities : activities.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES);
1210−
1211− for (const activity of visibleActivities) {
1212− result.push({
1213− type: "activity-group",
1214− id: activity.id,
1215− createdAt: activity.createdAt,
1216− turnId: activity.turnId,
1217− activities: [activity],
1218− });
1219− }
1220− result.push({
1221− type: "work-toggle",
1222− id: `work-toggle:${groupId}`,
1223− createdAt: entry.createdAt,
1224− turnId: entry.turnId,
1225− groupId,
1226− hiddenCount,
1227− expanded,
1228− onlyToolActivities: activities.every((activity) => activity.toolLike),
1229− });
1230−}
1231−
1232−/**
1233− * Sorts activities into lifecycle order. `derivePendingApprovals` and
1234− * `derivePendingUserInputs` both expect this ordering; sorting once and
1235− * passing the result to both avoids re-sorting the full activity history
1236− * per derivation.
1237− */
1238−export function sortThreadActivities(
1239− activities: ReadonlyArray<OrchestrationThreadActivity>,
1240−): ReadonlyArray<OrchestrationThreadActivity> {
1241− return Arr.sort(activities, activityOrder);
1242−}
1243−
1244−export function derivePendingApprovals(
1245− sortedActivities: ReadonlyArray<OrchestrationThreadActivity>,
1246−): PendingApproval[] {
1247− const openByRequestId = new Map<ApprovalRequestId, PendingApproval>();
1248−
1249− for (const activity of sortedActivities) {
1250− const payload =
1251− activity.payload && typeof activity.payload === "object"
1252− ? (activity.payload as Record<string, unknown>)
1253− : null;
1254− const requestId = parseApprovalRequestId(payload?.requestId);
1255− const requestKind =
1256− payload?.requestKind === "command" ||
1257− payload?.requestKind === "file-read" ||
1258− payload?.requestKind === "file-change"
1259− ? payload.requestKind
1260− : requestKindFromRequestType(payload?.requestType);
1261− const detail = typeof payload?.detail === "string" ? payload.detail : undefined;
1262−
1263− if (activity.kind === "approval.requested" && requestId && requestKind) {
1264− openByRequestId.set(requestId, {
1265− requestId,
1266− requestKind,
1267− createdAt: activity.createdAt,
1268− ...(detail ? { detail } : {}),
1269− });
1270− continue;
1271− }
1272−
1273− if (activity.kind === "approval.resolved" && requestId) {
1274− openByRequestId.delete(requestId);
1275− continue;
1276− }
1277−
1278− if (
1279− activity.kind === "provider.approval.respond.failed" &&
1280− requestId &&
1281− isStalePendingRequestFailureDetail(detail)
1282− ) {
1283− openByRequestId.delete(requestId);
1284− }
1285− }
1286−
1287− return Arr.sortWith([...openByRequestId.values()], (s) => new Date(s.createdAt), Order.Date);
1288−}
1289−
1290−export function derivePendingUserInputs(
1291− sortedActivities: ReadonlyArray<OrchestrationThreadActivity>,
1292−): PendingUserInput[] {
1293− const openByRequestId = new Map<ApprovalRequestId, PendingUserInput>();
1294−
1295− for (const activity of sortedActivities) {
1296− const payload =
1297− activity.payload && typeof activity.payload === "object"
1298− ? (activity.payload as Record<string, unknown>)
1299− : null;
1300− const requestId = parseApprovalRequestId(payload?.requestId);
1301− const detail = typeof payload?.detail === "string" ? payload.detail : undefined;
1302−
1303− if (activity.kind === "user-input.requested" && requestId) {
1304− const questions = parseUserInputQuestions(payload);
1305− if (!questions) {
1306− continue;
1307− }
1308− openByRequestId.set(requestId, {
1309− requestId,
1310− createdAt: activity.createdAt,
1311− questions,
1312− });
1313− continue;
1314− }
1315−
1316− if (activity.kind === "user-input.resolved" && requestId) {
1317− openByRequestId.delete(requestId);
1318− continue;
1319− }
1320−
1321− if (
1322− activity.kind === "provider.user-input.respond.failed" &&
1323− requestId &&
1324− isStalePendingRequestFailureDetail(detail)
1325− ) {
1326− openByRequestId.delete(requestId);
1327− }
1328− }
1329−
1330− return Arr.sortWith(openByRequestId.values(), (s) => new Date(s.createdAt), Order.Date);
1331−}
1332−
1333404 export function setPendingUserInputCustomAnswer(
1334405 draft: PendingUserInputDraftAnswer | undefined,
1335406 customAnswer: string,
1336407 ): PendingUserInputDraftAnswer {
1337408 const selectedOptionLabel =
1338409 customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel;
1339− return {
1340− customAnswer,
1341− ...(selectedOptionLabel ? { selectedOptionLabel } : {}),
1342− };
410+ return { customAnswer, ...(selectedOptionLabel ? { selectedOptionLabel } : {}) };
1343411 }
1344412
1345413 export function buildPendingUserInputAnswers(
1346− questions: ReadonlyArray<UserInputQuestion>,
414+ questions: ReadonlyArray<ThreadUserInputQuestion>,
1347415 draftAnswers: Record<string, PendingUserInputDraftAnswer>,
1348416 ): Record<string, string> | null {
1349417 const answers: Record<string, string> = {};
1350−
1351418 for (const question of questions) {
1352419 const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]);
1353− if (!answer) {
1354− return null;
1355− }
420+ if (!answer) return null;
1356421 answers[question.id] = answer;
1357422 }
1358−
1359423 return answers;
1360424 }
1361425
1362426 export function buildThreadFeed(
1363− thread: OrchestrationThread,
1364− options?: {
1365− readonly loadedMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>;
1366− },
427+ thread: EnvironmentThread,
428+ options?: { readonly loadedMessages?: ReadonlyArray<ThreadConversationMessage> },
1367429 ): ThreadFeedEntry[] {
1368430 const loadedMessages = options?.loadedMessages ?? thread.messages;
1369431 const oldestLoadedMessageCreatedAt =
1370− options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null;
1371− const workLogEntries = deriveWorkLogEntries(thread.activities);
1372− const entries = Arr.sortWith(
1373− [
1374− ...loadedMessages.map<RawThreadFeedEntry>((message) => ({
1375− type: "message",
1376− id: message.id,
1377− createdAt: message.createdAt,
1378− message,
432+ options?.loadedMessages === undefined ? null : (loadedMessages[0]?.createdAt ?? null);
433+ const entries: RawThreadFeedEntry[] = [
434+ ...loadedMessages.map((message) => ({
435+ type: "message" as const,
436+ id: message.id,
437+ createdAt: message.createdAt,
438+ message,
439+ })),
440+ ...thread.workEntries
441+ .filter(
442+ (entry) =>
443+ oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt,
444+ )
445+ .map((entry) => ({
446+ type: "activity" as const,
447+ id: entry.id,
448+ createdAt: entry.createdAt,
449+ runId: entry.runId,
450+ activity: toFeedActivity(entry),
1379451 })),
1380− ...workLogEntries
1381− .filter((entry) => {
1382− if (options?.loadedMessages === undefined) {
1383− return true;
1384− }
1385− return (
1386− oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt
1387− );
1388− })
1389− .map<RawThreadFeedEntry>((entry) => {
1390− const summary = workEntryHeading(entry);
1391− const detail = workEntryPreview(entry);
1392− const getFullDetail = memoizeValue(() => buildWorkEntryExpandedBody(entry));
1393− const getCopyText = memoizeValue(() =>
1394− [summary, detail, getFullDetail()]
1395− .filter((value, index, values): value is string => {
1396− return Boolean(value) && values.indexOf(value) === index;
1397− })
1398− .join("\n"),
1399− );
1400− return {
1401− type: "activity",
1402− id: entry.id,
1403− createdAt: entry.createdAt,
1404− turnId: entry.turnId,
1405− activity: {
1406− id: entry.id,
1407− createdAt: entry.createdAt,
1408− turnId: entry.turnId,
1409− summary,
1410− detail,
1411− canExpand: workEntryHasExpandedBody(entry),
1412− getFullDetail,
1413− getCopyText,
1414− icon: workEntryIcon(entry),
1415− toolLike: workLogEntryIsToolLike(entry),
1416− status: workEntryStatus(entry),
1417− },
1418− };
1419− }),
1420− ],
1421− (s) => new Date(s.createdAt),
1422− Order.Date,
1423− );
1424−
1425− return groupAdjacentActivities(entries);
452+ ];
453+ return groupAdjacentActivities(entries.toSorted(byCreatedAt));
1426454 }
apps/mobile/src/state/queries.ts+3−8
@@ -1,10 +1,5 @@
1−import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts";
2−import {
3− createThreadSearchResultsAtomFamily,
4− makeThreadSearchKey,
5− type EnvironmentThreadSearchMatch,
6−} from "@t3tools/client-runtime/state/thread-search";
7−import { useAtomValue } from "@effect/atom-react";
1+import type { EnvironmentThread } from "@t3tools/client-runtime/state/shell";
2+import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
83 import * as Option from "effect/Option";
94 import { Atom } from "effect/unstable/reactivity";
105 import { useEffect, useMemo, useState } from "react";
@@ -40,7 +35,7 @@ const threadSearchResultsAtom = createThreadSearchResultsAtomFamily({ · 29 unmodified lines
4035 });
4136
4237 export interface ThreadDetailView {
43− readonly data: OrchestrationThread | null;
38+ readonly data: EnvironmentThread | null;
4439 readonly error: string | null;
4540 readonly isPending: boolean;
4641 readonly isDeleted: boolean;
apps/mobile/src/state/threads.ts+10−2
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
77 type EnvironmentThreadState,
88 createThreadEnvironmentAtoms,
99 } from "@t3tools/client-runtime/state/threads";
10+import { presentThread, type EnvironmentThread } from "@t3tools/client-runtime/state/shell";
1011 import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
1112 import * as Option from "effect/Option";
1213 import { AsyncResult, Atom } from "effect/unstable/reactivity";
@@ -32,14 +33,21 @@ const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_ · 19 unmodified lines
3233 export function useEnvironmentThread(
3334 environmentId: EnvironmentId | null,
3435 threadId: ThreadId | null,
35−): EnvironmentThreadState {
36+): Omit<EnvironmentThreadState, "data"> & { readonly data: Option.Option<EnvironmentThread> } {
3637 const result = useAtomValue(
3738 environmentId !== null && threadId !== null
3839 ? environmentThreads.stateAtom(environmentId, threadId)
3940 : EMPTY_THREAD_STATE_ATOM,
4041 );
41− return Option.getOrElse(
42+ const state = Option.getOrElse(
4243 AsyncResult.value(result),
4344 () => EMPTY_ENVIRONMENT_THREAD_STATE,
4445 ) as EnvironmentThreadState;
46+ return {
47+ ...state,
48+ data:
49+ environmentId === null
50+ ? Option.none()
51+ : Option.map(state.data, (projection) => presentThread(environmentId, projection)),
52+ };
4553 }
apps/mobile/src/state/use-selected-thread-requests.ts+23−17
@@ -1,15 +1,13 @@
11 import { useAtomValue } from "@effect/atom-react";
22 import { useCallback, useMemo, useState } from "react";
33
4−import { ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts";
4+import { type ProviderApprovalDecision, type RuntimeRequestId } from "@t3tools/contracts";
55 import { Atom } from "effect/unstable/reactivity";
66
77 import { threadEnvironment } from "../state/threads";
88 import { scopedRequestKey } from "../lib/scopedEntities";
99 import {
1010 buildPendingUserInputAnswers,
11− derivePendingApprovals,
12− derivePendingUserInputs,
1311 setPendingUserInputCustomAnswer,
1412 sortThreadActivities,
1513 type PendingUserInputDraftAnswer,
@@ -66,14 +64,11 @@ export function useSelectedThreadRequests() { · 50 unmodified lines
6664 const { selectedThread: selectedThreadShell } = useThreadSelection();
6765 const selectedThread = useSelectedThreadDetail();
6866 const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom);
69− const [respondingApprovalId, setRespondingApprovalId] = useState<ApprovalRequestId | null>(null);
70− const [respondingUserInputId, setRespondingUserInputId] = useState<ApprovalRequestId | null>(
71− null,
72− );
67+ const [respondingApprovalId, setRespondingApprovalId] = useState<RuntimeRequestId | null>(null);
68+ const [respondingUserInputId, setRespondingUserInputId] = useState<RuntimeRequestId | null>(null);
7369
74− // Sort once; both derivations expect the same lifecycle ordering.
75− const sortedActivities = useMemo(
76− () => (selectedThread ? sortThreadActivities(selectedThread.activities) : []),
70+ const activePendingApprovals = useMemo(
71+ () => selectedThread?.pendingApprovals ?? [],
7772 [selectedThread],
7873 );
7974 const activePendingApprovals = useMemo(
@@ -82,8 +77,8 @@ export function useSelectedThreadRequests() { · 2 unmodified lines
8277 );
8378 const activePendingApproval = activePendingApprovals[0] ?? null;
8479 const activePendingUserInputs = useMemo(
85− () => derivePendingUserInputs(sortedActivities),
86− [sortedActivities],
80+ () => selectedThread?.pendingUserInputs ?? [],
81+ [selectedThread],
8782 );
8883 const activePendingUserInput = activePendingUserInputs[0] ?? null;
8984 const activePendingUserInputDrafts =
@@ -97,7 +92,7 @@ export function useSelectedThreadRequests() { · 7 unmodified lines
9792 : null;
9893
9994 const onSelectUserInputOption = useCallback(
100− (requestId: ApprovalRequestId, questionId: string, label: string) => {
95+ (requestId: RuntimeRequestId, questionId: string, label: string) => {
10196 if (!selectedThreadShell) {
10297 return;
10398 }
@@ -109,7 +104,7 @@ export function useSelectedThreadRequests() { · 5 unmodified lines
109104 );
110105
111106 const onChangeUserInputCustomAnswer = useCallback(
112− (requestId: ApprovalRequestId, questionId: string, customAnswer: string) => {
107+ (requestId: RuntimeRequestId, questionId: string, customAnswer: string) => {
113108 if (!selectedThreadShell) {
114109 return;
115110 }
@@ -121,10 +116,16 @@ export function useSelectedThreadRequests() { · 5 unmodified lines
121116 );
122117
123118 const onRespondToApproval = useCallback(
124− async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => {
119+ async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => {
125120 if (!selectedThreadShell) {
126121 return;
127122 }
123+ if (
124+ activePendingApprovals.find((approval) => approval.requestId === requestId)
125+ ?.responseCapability !== "live"
126+ ) {
127+ return;
128+ }
128129
129130 setRespondingApprovalId(requestId);
130131 const result = await respondToApproval({
@@ -138,11 +139,16 @@ export function useSelectedThreadRequests() { · 7 unmodified lines
138139 setRespondingApprovalId((current) => (current === requestId ? null : current));
139140 return result;
140141 },
141− [respondToApproval, selectedThreadShell],
142+ [activePendingApprovals, respondToApproval, selectedThreadShell],
142143 );
143144
144145 const onSubmitUserInput = useCallback(async () => {
145− if (!selectedThreadShell || !activePendingUserInput || !activePendingUserInputAnswers) {
146+ if (
147+ !selectedThreadShell ||
148+ !activePendingUserInput ||
149+ activePendingUserInput.responseCapability !== "live" ||
150+ !activePendingUserInputAnswers
151+ ) {
146152 return;
147153 }
148154
apps/mobile/src/state/use-thread-composer-state.ts+6−7
@@ -1,4 +1,5 @@
11 import { useAtomValue } from "@effect/atom-react";
2+import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell";
23 import { useCallback, useEffect, useMemo } from "react";
34
45 import {
@@ -106,13 +107,13 @@ export function useThreadComposerState() { · 101 unmodified lines
106107
107108 const selectedThreadSessionActivity = useMemo(() => {
108109 const selectedThread = selectedThreadDetail ?? selectedThreadShell;
109− if (!selectedThread?.session) {
110+ if (!selectedThread?.runtime) {
110111 return null;
111112 }
112113
113114 return {
114− orchestrationStatus: selectedThread.session.status,
115− activeTurnId: selectedThread.session.activeTurnId ?? undefined,
115+ orchestrationStatus: selectedThread.runtime.status,
116+ activeRunId: selectedThread.runtime.activeRunId ?? undefined,
116117 };
117118 }, [selectedThreadDetail, selectedThreadShell]);
118119
@@ -123,15 +124,13 @@ export function useThreadComposerState() { · 4 unmodified lines
123124 }
124125
125126 return deriveActiveWorkStartedAt(
126− selectedThread.latestTurn,
127+ selectedThread.latestRun,
127128 selectedThreadSessionActivity,
128129 null,
129130 );
130131 }, [selectedThreadDetail, selectedThreadSessionActivity, selectedThreadShell]);
131132
132− const activeThreadBusy =
133− !!selectedThread &&
134− (selectedThread.session?.status === "running" || selectedThread.session?.status === "starting");
133+ const activeThreadBusy = !!selectedThread && threadRuntimeIsActive(selectedThread.runtime);
135134
136135 const onSendMessage = useCallback(async () => {
137136 if (!selectedThreadShell) {
apps/mobile/src/state/use-thread-outbox-drain.ts+5−4
@@ -1,7 +1,7 @@
11 import { useAtomValue } from "@effect/atom-react";
2−import type {
3− EnvironmentProject,
4− EnvironmentThreadShell,
2+import {
3+ threadRuntimeIsActive,
4+ type EnvironmentThreadShell,
55 } from "@t3tools/client-runtime/state/shell";
66 import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
77 import {
@@ -221,6 +221,7 @@ export function useThreadOutboxDrain(): void { · 213 unmodified lines
221221 environmentId: queuedMessage.environmentId,
222222 input: {
223223 commandId: queuedMessage.commandId,
224+ creationSource: "mobile",
224225 threadId: queuedMessage.threadId,
225226 message: {
226227 messageId: queuedMessage.messageId,
@@ -314,7 +315,7 @@ export function useThreadOutboxDrain(): void { · 87 unmodified lines
314315 threadExists: thread !== undefined,
315316 shellStatus,
316317 environmentConnected: environment?.connectionState === "connected",
317− threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting",
318+ threadBusy: threadRuntimeIsActive(thread?.runtime),
318319 });
319320 if (deliveryAction === "wait") {
320321 continue;
apps/mobile/src/test-fixtures.ts+130−0
@@ -0,0 +1,130 @@
1+import {
2+ presentThread,
3+ presentThreadShell,
4+ type EnvironmentThread,
5+ type EnvironmentThreadShell,
6+} from "@t3tools/client-runtime/state/shell";
7+import {
8+ EnvironmentId,
9+ ProjectId,
10+ ProviderInstanceId,
11+ ThreadId,
12+ type OrchestrationV2ThreadProjection,
13+ type OrchestrationV2ThreadShell,
14+} from "@t3tools/contracts";
15+import * as DateTime from "effect/DateTime";
16+
17+const DEFAULT_TIMESTAMP = "2026-01-01T00:00:00.000Z";
18+
19+export function makeRawThreadShell(
20+ input: Partial<OrchestrationV2ThreadShell> = {},
21+): OrchestrationV2ThreadShell {
22+ const id = input.id ?? ThreadId.make("thread-test");
23+ const providerInstanceId = input.providerInstanceId ?? ProviderInstanceId.make("codex");
24+ const now = DateTime.makeUnsafe(DEFAULT_TIMESTAMP);
25+ return {
26+ id,
27+ projectId: ProjectId.make("project-test"),
28+ title: "Thread",
29+ providerInstanceId,
30+ modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" },
31+ runtimeMode: "full-access",
32+ interactionMode: "default",
33+ branch: null,
34+ worktreePath: null,
35+ activeProviderThreadId: null,
36+ lineage: { rootThreadId: id, parentThreadId: null, relationshipToParent: null },
37+ forkedFrom: null,
38+ createdBy: "user",
39+ creationSource: "mobile",
40+ latestRunId: null,
41+ activeRunId: null,
42+ status: "idle",
43+ pendingRuntimeRequest: null,
44+ latestVisibleMessage: null,
45+ latestUserMessageAt: null,
46+ hasActionableProposedPlan: false,
47+ itemCount: 0,
48+ visibleItemCount: 0,
49+ createdAt: now,
50+ updatedAt: now,
51+ archivedAt: null,
52+ deletedAt: null,
53+ ...input,
54+ };
55+}
56+
57+export function makeThreadShellFixture(
58+ overrides: Partial<EnvironmentThreadShell> = {},
59+): EnvironmentThreadShell {
60+ const environmentId = overrides.environmentId ?? EnvironmentId.make("environment-test");
61+ const raw = makeRawThreadShell({
62+ id: overrides.id,
63+ projectId: overrides.projectId,
64+ title: overrides.title,
65+ providerInstanceId: overrides.providerInstanceId,
66+ modelSelection: overrides.modelSelection,
67+ runtimeMode: overrides.runtimeMode,
68+ interactionMode: overrides.interactionMode,
69+ branch: overrides.branch,
70+ worktreePath: overrides.worktreePath,
71+ });
72+ return { ...presentThreadShell(environmentId, raw), ...overrides };
73+}
74+
75+export function makeThreadFixture(overrides: Partial<EnvironmentThread> = {}): EnvironmentThread {
76+ const shell = makeRawThreadShell({
77+ id: overrides.id,
78+ projectId: overrides.projectId,
79+ title: overrides.title,
80+ providerInstanceId: overrides.providerInstanceId,
81+ modelSelection: overrides.modelSelection,
82+ runtimeMode: overrides.runtimeMode,
83+ interactionMode: overrides.interactionMode,
84+ branch: overrides.branch,
85+ worktreePath: overrides.worktreePath,
86+ });
87+ const projection: OrchestrationV2ThreadProjection = {
88+ thread: {
89+ id: shell.id,
90+ projectId: shell.projectId,
91+ title: shell.title,
92+ providerInstanceId: shell.providerInstanceId,
93+ modelSelection: shell.modelSelection,
94+ runtimeMode: shell.runtimeMode,
95+ interactionMode: shell.interactionMode,
96+ branch: shell.branch,
97+ worktreePath: shell.worktreePath,
98+ activeProviderThreadId: shell.activeProviderThreadId,
99+ lineage: shell.lineage,
100+ forkedFrom: shell.forkedFrom,
101+ createdBy: shell.createdBy,
102+ creationSource: shell.creationSource,
103+ createdAt: shell.createdAt,
104+ updatedAt: shell.updatedAt,
105+ archivedAt: shell.archivedAt,
106+ deletedAt: shell.deletedAt,
107+ },
108+ runs: [],
109+ attempts: [],
110+ nodes: [],
111+ subagents: [],
112+ providerSessions: [],
113+ providerThreads: [],
114+ providerTurns: [],
115+ runtimeRequests: [],
116+ messages: [],
117+ plans: [],
118+ turnItems: [],
119+ checkpointScopes: [],
120+ checkpoints: [],
121+ contextHandoffs: [],
122+ contextTransfers: [],
123+ visibleTurnItems: [],
124+ updatedAt: shell.updatedAt,
125+ };
126+ return {
127+ ...presentThread(overrides.environmentId ?? EnvironmentId.make("environment-test"), projection),
128+ ...overrides,
129+ };
130+}
apps/server/README.md+3−0
@@ -0,0 +1,3 @@
1+# Server
2+
3+Node.js WebSocket server for the T3 Code app.
apps/server/src/attachmentStore.test.ts+11−0
@@ -7,11 +7,22 @@ import { describe, expect, it } from "vite-plus/test"; · 6 unmodified lines
77
88 import {
99 createAttachmentId,
10+ createDeterministicAttachmentId,
1011 parseThreadSegmentFromAttachmentId,
1112 resolveAttachmentPathById,
1213 } from "./attachmentStore.ts";
1314
1415 describe("attachmentStore", () => {
16+ it("derives stable attachment ids for idempotent message retries", () => {
17+ const first = createDeterministicAttachmentId("thread-1", "message-1:0");
18+ const retry = createDeterministicAttachmentId("thread-1", "message-1:0");
19+ const next = createDeterministicAttachmentId("thread-1", "message-1:1");
20+
21+ expect(first).toBe(retry);
22+ expect(next).not.toBe(first);
23+ expect(first && parseThreadSegmentFromAttachmentId(first)).toBe("thread-1");
24+ });
25+
1526 it("sanitizes thread ids when creating attachment ids", () => {
1627 const attachmentId = createAttachmentId("thread.folder/unsafe space");
1728 expect(attachmentId).toBeTruthy();
apps/server/src/attachmentStore.ts+11−0
@@ -42,6 +42,17 @@ export function createAttachmentId(threadId: string): string | null { · 41 unmodified lines
4242 return `${threadSegment}-${NodeCrypto.randomUUID()}`;
4343 }
4444
45+export function createDeterministicAttachmentId(
46+ threadId: string,
47+ stableKey: string,
48+): string | null {
49+ const threadSegment = toSafeThreadAttachmentSegment(threadId);
50+ if (!threadSegment) return null;
51+ const hash = NodeCrypto.createHash("sha256").update(stableKey).digest("hex").slice(0, 32);
52+ const uuid = `${hash.slice(0, 8)}-${hash.slice(8, 12)}-${hash.slice(12, 16)}-${hash.slice(16, 20)}-${hash.slice(20)}`;
53+ return `${threadSegment}-${uuid}`;
54+}
55+
4556 export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null {
4657 const normalizedId = normalizeAttachmentRelativePath(attachmentId);
4758 if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {
apps/server/src/orchestration-v2/FoundationPersistence.test.ts+30−0
@@ -11,7 +11,9 @@ import { · 10 unmodified lines
1111 type OrchestrationV2AppThread,
1212 type OrchestrationV2DomainEvent,
1313 ProjectId,
14+ ProviderDriverKind,
1415 ProviderInstanceId,
16+ ProviderThreadId,
1517 RunId,
1618 ThreadId,
1719 TurnItemId,
@@ -60,6 +62,7 @@ const TestLayer = Layer.mergeAll( · 42 unmodified lines
6062 );
6163
6264 const providerInstanceId = ProviderInstanceId.make("codex");
65+const providerDriver = ProviderDriverKind.make("codex");
6366 const modelSelection = {
6467 instanceId: providerInstanceId,
6568 model: "gpt-5.4",
@@ -404,7 +407,32 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { · 338 unmodified lines
404407 const now = yield* DateTime.now;
405408 const threadId = ThreadId.make("thread:foundation-many-items");
406409 const runId = RunId.make("run:foundation-many-items");
410+ const providerThreadId = ProviderThreadId.make("provider-thread:foundation-many-items");
407411 const thread = makeThread(threadId, now);
412+ const providerThreadEvent = {
413+ id: EventId.make("event:foundation-many-items:provider-thread"),
414+ type: "provider-thread.updated" as const,
415+ threadId,
416+ providerInstanceId,
417+ occurredAt: now,
418+ payload: {
419+ id: providerThreadId,
420+ driver: providerDriver,
421+ providerInstanceId,
422+ providerSessionId: null,
423+ appThreadId: threadId,
424+ ownerNodeId: null,
425+ nativeThreadRef: null,
426+ nativeConversationHeadRef: null,
427+ status: "active" as const,
428+ firstRunOrdinal: 1,
429+ lastRunOrdinal: 1,
430+ handoffIds: [],
431+ forkedFrom: null,
432+ createdAt: now,
433+ updatedAt: now,
434+ },
435+ } satisfies OrchestrationV2DomainEvent;
408436 const runEvent = {
409437 id: EventId.make("event:foundation-many-items:run"),
410438 type: "run.created" as const,
@@ -467,11 +495,13 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { · 56 unmodified lines
467495 yield* eventSink.write({
468496 events: [
469497 threadCreatedEvent({ id: "event:foundation-many-items:thread", thread, now }),
498+ providerThreadEvent,
470499 runEvent,
471500 ...itemEvents,
472501 ],
473502 });
474503 const beforeUpdate = yield* projectionStore.getThreadProjection(threadId);
504+ assert.equal(beforeUpdate.thread.activeProviderThreadId, providerThreadId);
475505 const ordinals = beforeUpdate.turnItems.map((item) => item.ordinal);
476506 assert.lengthOf(ordinals, 151);
477507 assert.equal(new Set(ordinals).size, 151);
apps/server/src/orchestration-v2/Orchestrator.ts+54−0
@@ -1649,6 +1649,54 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 1648 unmodified lines
16491649 const projection = yield* getProjectionWithPendingEvents(command.threadId, events);
16501650 const modelSelection = command.modelSelection ?? projection.thread.modelSelection;
16511651 const dispatchMode = command.dispatchMode;
1652+ const sourcePlanProjection =
1653+ command.sourcePlanRef === undefined
1654+ ? null
1655+ : yield* getProjectionWithPendingEvents(command.sourcePlanRef.threadId, events);
1656+ const sourcePlan =
1657+ command.sourcePlanRef === undefined
1658+ ? null
1659+ : (sourcePlanProjection?.plans.find(
1660+ (plan) => plan.id === command.sourcePlanRef?.planId && plan.kind === "proposed_plan",
1661+ ) ?? null);
1662+ if (command.sourcePlanRef !== undefined && sourcePlan === null) {
1663+ return yield* new OrchestratorDispatchError({
1664+ commandId: command.commandId,
1665+ commandType: command.type,
1666+ cause: `Proposed plan ${command.sourcePlanRef.planId} does not exist on thread ${command.sourcePlanRef.threadId}.`,
1667+ });
1668+ }
1669+ if (
1670+ sourcePlanProjection !== null &&
1671+ sourcePlanProjection.thread.projectId !== projection.thread.projectId
1672+ ) {
1673+ return yield* new OrchestratorDispatchError({
1674+ commandId: command.commandId,
1675+ commandType: command.type,
1676+ cause: `Proposed plan ${command.sourcePlanRef?.planId} belongs to a different project.`,
1677+ });
1678+ }
1679+ if (sourcePlan !== null && sourcePlan.status !== "active") {
1680+ return yield* new OrchestratorDispatchError({
1681+ commandId: command.commandId,
1682+ commandType: command.type,
1683+ cause: `Proposed plan ${sourcePlan.id} is not active.`,
1684+ });
1685+ }
1686+ const completeSourcePlan = (occurredAt: DateTime.Utc) =>
1687+ sourcePlan === null
1688+ ? Effect.void
1689+ : emit(
1690+ events,
1691+ command,
1692+ )({
1693+ type: "plan.updated",
1694+ threadId: sourcePlan.threadId,
1695+ ...(sourcePlan.runId === null ? {} : { runId: sourcePlan.runId }),
1696+ nodeId: sourcePlan.nodeId,
1697+ occurredAt,
1698+ payload: { ...sourcePlan, status: "completed" },
1699+ });
16521700
16531701 if (dispatchMode.type === "steer_active" || dispatchMode.type === "restart_active") {
16541702 yield* dispatchSteerIntoRun({
@@ -1783,6 +1831,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 128 unmodified lines
17831831 completedAt: null,
17841832 checkpointId: null,
17851833 contextHandoffId: null,
1834+ ...(command.sourcePlanRef === undefined ? {} : { sourcePlanRef: command.sourcePlanRef }),
17861835 };
17871836 const attempt: OrchestrationV2RunAttempt = {
17881837 id: attemptId,
@@ -1861,6 +1910,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 72 unmodified lines
18611910 occurredAt: now,
18621911 payload: run,
18631912 });
1913+ yield* completeSourcePlan(now);
18641914 yield* emitEvent({
18651915 type: "run-attempt.created",
18661916 threadId: command.threadId,
@@ -2030,6 +2080,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 163 unmodified lines
20302080 completedAt: null,
20312081 checkpointId: null,
20322082 contextHandoffId: null,
2083+ ...(command.sourcePlanRef === undefined ? {} : { sourcePlanRef: command.sourcePlanRef }),
20332084 };
20342085 const attempt: OrchestrationV2RunAttempt = {
20352086 id: attemptId,
@@ -2116,6 +2167,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 80 unmodified lines
21162167 occurredAt: now,
21172168 payload: run,
21182169 });
2170+ yield* completeSourcePlan(now);
21192171 yield* emitEvent({
21202172 type: "run-attempt.created",
21212173 threadId: command.threadId,
@@ -2613,6 +2665,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 491 unmodified lines
26132665 checkpointId: null,
26142666 contextHandoffId:
26152667 portableForkHandoff?.id ?? providerSwitchHandoff?.id ?? mergeBackHandoff?.id ?? null,
2668+ ...(command.sourcePlanRef === undefined ? {} : { sourcePlanRef: command.sourcePlanRef }),
26162669 };
26172670 const attempt: OrchestrationV2RunAttempt = {
26182671 id: attemptId,
@@ -2917,6 +2970,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 298 unmodified lines
29172970 occurredAt: now,
29182971 payload: run,
29192972 });
2973+ yield* completeSourcePlan(now);
29202974 yield* emitEvent({
29212975 type: "run-attempt.created",
29222976 threadId: command.threadId,
apps/server/src/orchestration-v2/ProjectionStore.ts+44−0
@@ -223,6 +223,13 @@ export function applyToProjection( · 222 unmodified lines
223223 case "provider-thread.updated":
224224 return {
225225 ...base,
226+ thread:
227+ event.payload.appThreadId === base.thread.id
228+ ? {
229+ ...base.thread,
230+ activeProviderThreadId: event.payload.id,
231+ }
232+ : base.thread,
226233 providerThreads: upsertById(base.providerThreads, event.payload),
227234 };
228235 case "provider-turn.updated":
@@ -288,6 +295,8 @@ type ShellThreadRow = { · 59 unmodified lines
288295 readonly active_run_id: string | null;
289296 readonly pending_request_payload_json: string | null;
290297 readonly latest_message_payload_json: string | null;
298+ readonly latest_user_message_at: string | null;
299+ readonly has_actionable_proposed_plan: number;
291300 readonly item_count: number;
292301 };
293302
@@ -638,6 +647,13 @@ export function threadShellFromProjection( · 344 unmodified lines
638647 (left, right) =>
639648 DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt),
640649 )[0] ?? null;
650+ const latestUserMessage =
651+ projection.messages
652+ .filter((message) => message.role === "user")
653+ .toSorted(
654+ (left, right) =>
655+ DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt),
656+ )[0] ?? null;
641657 return {
642658 createdBy: projection.thread.createdBy,
643659 creationSource: projection.thread.creationSource,
@@ -673,6 +689,10 @@ export function threadShellFromProjection( · 29 unmodified lines
673689 text: latestVisibleMessage.text,
674690 updatedAt: latestVisibleMessage.updatedAt,
675691 },
692+ latestUserMessageAt: latestUserMessage?.updatedAt ?? null,
693+ hasActionableProposedPlan: projection.plans.some(
694+ (plan) => plan.kind === "proposed_plan" && plan.status === "active",
695+ ),
676696 itemCount: activeLocalTurnItems(projection).length,
677697 visibleItemCount: projection.visibleTurnItems.length,
678698 createdAt: projection.thread.createdAt,
@@ -693,6 +713,8 @@ type ShellThreadState = { · 14 unmodified lines
693713 readonly activeRunId: RunId | null;
694714 readonly pendingRuntimeRequest: OrchestrationV2ThreadProjection["runtimeRequests"][number] | null;
695715 readonly latestVisibleMessage: OrchestrationV2ConversationMessage | null;
716+ readonly latestUserMessageAt: DateTime.Utc | null;
717+ readonly hasActionableProposedPlan: boolean;
696718 readonly itemCount: number;
697719 readonly updatedAt: OrchestrationV2ThreadProjection["updatedAt"];
698720 readonly runOrdinalById: ReadonlyMap<RunId, number>;
@@ -819,6 +841,8 @@ function shellFromState(input: { · 120 unmodified lines
819841 text: input.state.latestVisibleMessage.text,
820842 updatedAt: input.state.latestVisibleMessage.updatedAt,
821843 },
844+ latestUserMessageAt: input.state.latestUserMessageAt,
845+ hasActionableProposedPlan: input.state.hasActionableProposedPlan,
822846 itemCount: input.state.itemCount,
823847 visibleItemCount: input.visibleItemCount,
824848 createdAt: input.state.thread.createdAt,
@@ -1949,6 +1973,21 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 1124 unmodified lines
19491973 ORDER BY message.updated_at DESC, message.message_id DESC
19501974 LIMIT 1
19511975 ) AS latest_message_payload_json,
1976+ (
1977+ SELECT message.updated_at
1978+ FROM orchestration_v2_projection_messages message
1979+ WHERE message.thread_id = t.thread_id
1980+ AND message.role = 'user'
1981+ ORDER BY message.updated_at DESC, message.message_id DESC
1982+ LIMIT 1
1983+ ) AS latest_user_message_at,
1984+ EXISTS (
1985+ SELECT 1
1986+ FROM orchestration_v2_projection_plans plan
1987+ WHERE plan.thread_id = t.thread_id
1988+ AND plan.kind = 'proposed_plan'
1989+ AND plan.status = 'active'
1990+ ) AS has_actionable_proposed_plan,
19521991 (
19531992 SELECT COUNT(*)
19541993 FROM orchestration_v2_projection_turn_items i
@@ -2015,6 +2054,11 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 60 unmodified lines
20152054 activeRunId: row.active_run_id === null ? null : RunId.make(row.active_run_id),
20162055 pendingRuntimeRequest,
20172056 latestVisibleMessage,
2057+ latestUserMessageAt:
2058+ row.latest_user_message_at === null
2059+ ? null
2060+ : DateTime.makeUnsafe(row.latest_user_message_at),
2061+ hasActionableProposedPlan: row.has_actionable_proposed_plan === 1,
20182062 itemCount: row.item_count,
20192063 updatedAt: thread.updatedAt,
20202064 runOrdinalById:
apps/server/src/orchestration-v2/RuntimePolicy.test.ts+99−0
@@ -0,0 +1,99 @@
1+import { assert, it } from "@effect/vitest";
2+import {
3+ type ModelSelection,
4+ type OrchestrationV2AppThread,
5+ ProjectId,
6+ ProviderInstanceId,
7+ ThreadId,
8+} from "@t3tools/contracts";
9+import * as DateTime from "effect/DateTime";
10+import * as Effect from "effect/Effect";
11+import * as Layer from "effect/Layer";
12+import * as Option from "effect/Option";
13+
14+import * as ProjectionProjects from "../persistence/Services/ProjectionProjects.ts";
15+import { layerFromProjectRepository, RuntimePolicyV2 } from "./RuntimePolicy.ts";
16+
17+const projectId = ProjectId.make("project:runtime-policy");
18+const providerInstanceId = ProviderInstanceId.make("codex");
19+const modelSelection = {
20+ instanceId: providerInstanceId,
21+ model: "gpt-5.5",
22+} satisfies ModelSelection;
23+
24+function makeThread(input: {
25+ readonly now: DateTime.Utc;
26+ readonly worktreePath: string | null;
27+}): OrchestrationV2AppThread {
28+ const threadId = ThreadId.make("thread:runtime-policy");
29+ return {
30+ createdBy: "user",
31+ creationSource: "web",
32+ id: threadId,
33+ projectId,
34+ title: "Runtime policy",
35+ providerInstanceId,
36+ modelSelection,
37+ runtimeMode: "full-access",
38+ interactionMode: "default",
39+ branch: null,
40+ worktreePath: input.worktreePath,
41+ activeProviderThreadId: null,
42+ lineage: {
43+ parentThreadId: null,
44+ relationshipToParent: null,
45+ rootThreadId: threadId,
46+ },
47+ forkedFrom: null,
48+ createdAt: input.now,
49+ updatedAt: input.now,
50+ archivedAt: null,
51+ deletedAt: null,
52+ };
53+}
54+
55+const TestLayer = layerFromProjectRepository.pipe(
56+ Layer.provide(
57+ Layer.mock(ProjectionProjects.ProjectionProjectRepository)({
58+ getById: () =>
59+ Effect.succeed(
60+ Option.some({
61+ projectId,
62+ title: "Project",
63+ workspaceRoot: "/project-root",
64+ defaultModelSelection: null,
65+ scripts: [],
66+ createdAt: "2026-06-21T00:00:00.000Z",
67+ updatedAt: "2026-06-21T00:00:00.000Z",
68+ deletedAt: null,
69+ }),
70+ ),
71+ }),
72+ ),
73+);
74+
75+it.layer(TestLayer)("RuntimePolicyV2", (it) => {
76+ it.effect("uses the project root for local-checkout threads", () =>
77+ Effect.gen(function* () {
78+ const policy = yield* RuntimePolicyV2;
79+ const now = yield* DateTime.now;
80+ const resolved = yield* policy.resolve({
81+ thread: makeThread({ now, worktreePath: null }),
82+ modelSelection,
83+ });
84+ assert.equal(resolved.cwd, "/project-root");
85+ }),
86+ );
87+
88+ it.effect("prefers a provisioned worktree over the project root", () =>
89+ Effect.gen(function* () {
90+ const policy = yield* RuntimePolicyV2;
91+ const now = yield* DateTime.now;
92+ const resolved = yield* policy.resolve({
93+ thread: makeThread({ now, worktreePath: "/project-worktree" }),
94+ modelSelection,
95+ });
96+ assert.equal(resolved.cwd, "/project-worktree");
97+ }),
98+ );
99+});
apps/server/src/orchestration-v2/RuntimePolicy.ts+56−3
@@ -1,9 +1,16 @@
1−import { ModelSelection, OrchestrationV2AppThread, ProviderDriverKind } from "@t3tools/contracts";
1+import {
2+ ModelSelection,
3+ OrchestrationV2AppThread,
4+ ProjectId,
5+ ProviderInstanceId,
6+} from "@t3tools/contracts";
27 import * as Context from "effect/Context";
38 import * as Effect from "effect/Effect";
49 import * as Layer from "effect/Layer";
10+import * as Option from "effect/Option";
511 import * as Schema from "effect/Schema";
612
13+import * as ProjectionProjects from "../persistence/Services/ProjectionProjects.ts";
714 import {
815 ProviderAdapterV2RuntimePolicy,
916 type ProviderAdapterV2RuntimePolicy as ProviderAdapterV2RuntimePolicyType,
@@ -15,12 +22,13 @@ import { · 5 unmodified lines
1522 export class RuntimePolicyResolveError extends Schema.TaggedErrorClass<RuntimePolicyResolveError>()(
1623 "RuntimePolicyResolveError",
1724 {
18− driver: ProviderDriverKind,
25+ projectId: ProjectId,
26+ providerInstanceId: ProviderInstanceId,
1927 cause: Schema.optional(Schema.Defect()),
2028 },
2129 ) {
2230 override get message(): string {
23− return `Failed to resolve runtime policy for ${this.driver}.`;
31+ return `Failed to resolve runtime policy for provider instance ${this.providerInstanceId} in project ${this.projectId}.`;
2432 }
2533 }
2634
@@ -61,6 +69,51 @@ export const layer: Layer.Layer<RuntimePolicyV2> = Layer.succeed(RuntimePolicyV2 · 34 unmodified lines
6169 }),
6270 });
6371
72+export const layerFromProjectRepository: Layer.Layer<
73+ RuntimePolicyV2,
74+ never,
75+ ProjectionProjects.ProjectionProjectRepository
76+> = Layer.effect(
77+ RuntimePolicyV2,
78+ Effect.gen(function* () {
79+ const projects = yield* ProjectionProjects.ProjectionProjectRepository;
80+ return RuntimePolicyV2.of({
81+ resolve: Effect.fn("RuntimePolicyV2.resolve")(function* (input) {
82+ const cwd =
83+ input.thread.worktreePath ??
84+ (yield* projects.getById({ projectId: input.thread.projectId }).pipe(
85+ Effect.mapError(
86+ (cause) =>
87+ new RuntimePolicyResolveError({
88+ projectId: input.thread.projectId,
89+ providerInstanceId: input.modelSelection.instanceId,
90+ cause,
91+ }),
92+ ),
93+ Effect.flatMap(
94+ Option.match({
95+ onNone: () =>
96+ Effect.fail(
97+ new RuntimePolicyResolveError({
98+ projectId: input.thread.projectId,
99+ providerInstanceId: input.modelSelection.instanceId,
100+ cause: "Project not found.",
101+ }),
102+ ),
103+ onSome: (project) => Effect.succeed(project.workspaceRoot),
104+ }),
105+ ),
106+ ));
107+ return ProviderAdapterV2RuntimePolicy.make({
108+ runtimeMode: input.thread.runtimeMode,
109+ interactionMode: input.thread.interactionMode,
110+ cwd,
111+ });
112+ }),
113+ });
114+ }),
115+);
116+
64117 export function layerWithOverride(
65118 override: RuntimePolicyV2Override,
66119 ): Layer.Layer<RuntimePolicyV2, never, RuntimePolicyV2> {
apps/server/src/orchestration-v2/ThreadLaunchService.test.ts+147−6
@@ -80,6 +80,20 @@ const makeLayer = (options?: { readonly failCreate?: boolean; readonly failSetup · 79 unmodified lines
8080 (
8181 command: Parameters<ThreadManagement.ThreadManagementService["Service"]["dispatch"]>[0],
8282 ): Effect.Effect<OrchestratorV2DispatchResult, OrchestratorDispatchError> => {
83+ if (command.type === "thread.metadata.update") {
84+ const existing = projections.get(command.threadId);
85+ if (existing === undefined) return Effect.die("missing existing projection");
86+ projections.set(command.threadId, {
87+ ...existing,
88+ thread: {
89+ ...existing.thread,
90+ ...(command.title === undefined ? {} : { title: command.title }),
91+ ...(command.branch === undefined ? {} : { branch: command.branch }),
92+ ...(command.worktreePath === undefined ? {} : { worktreePath: command.worktreePath }),
93+ },
94+ });
95+ return Effect.succeed({ sequence: 1, storedEvents: [] });
96+ }
8397 if (command.type !== "thread.create") return Effect.die("unexpected command");
8498 if (options?.failCreate) {
8599 return Effect.fail(
@@ -90,16 +104,37 @@ const makeLayer = (options?: { readonly failCreate?: boolean; readonly failSetup · 4 unmodified lines
90104 }),
91105 );
92106 }
93− projections.set(command.threadId, projectionFor(command.threadId));
107+ const projection = projectionFor(command.threadId);
108+ projections.set(command.threadId, {
109+ ...projection,
110+ thread: {
111+ ...projection.thread,
112+ title: command.title,
113+ branch: command.branch,
114+ worktreePath: command.worktreePath,
115+ },
116+ });
94117 return Effect.succeed({ sequence: 1, storedEvents: [] });
95118 },
96119 );
97− const createWorktree = vi.fn(() =>
98− Effect.succeed({
99− worktree: { path: "/repo-worktrees/feature", refName: "feature", headSha: "abc" },
100− } as never),
120+ const createWorktree = vi.fn(
121+ (_input: Parameters<GitWorkflow.GitWorkflowService["Service"]["createWorktree"]>[0]) =>
122+ Effect.succeed({
123+ worktree: { path: "/repo-worktrees/feature", refName: "feature", headSha: "abc" },
124+ } as never),
101125 );
102126 const removeWorktree = vi.fn(() => Effect.void);
127+ const fetchRemote = vi.fn(
128+ (_input: Parameters<GitWorkflow.GitWorkflowService["Service"]["fetchRemote"]>[0]) =>
129+ Effect.void,
130+ );
131+ const resolveRemoteTrackingCommit = vi.fn(
132+ (
133+ _input: Parameters<
134+ GitWorkflow.GitWorkflowService["Service"]["resolveRemoteTrackingCommit"]
135+ >[0],
136+ ) => Effect.succeed({ commitSha: "remote-main-sha", remoteRefName: "origin/main" }),
137+ );
103138 const runForThread = vi.fn(() =>
104139 options?.failSetup
105140 ? Effect.fail(
@@ -126,7 +161,12 @@ const makeLayer = (options?: { readonly failCreate?: boolean; readonly failSetup · 20 unmodified lines
126161 getByWorkspaceRoot: () => Effect.succeed(Option.some(project)),
127162 snapshot: Effect.die("unused"),
128163 }),
129− Layer.mock(GitWorkflow.GitWorkflowService)({ createWorktree, removeWorktree }),
164+ Layer.mock(GitWorkflow.GitWorkflowService)({
165+ createWorktree,
166+ fetchRemote,
167+ removeWorktree,
168+ resolveRemoteTrackingCommit,
169+ }),
130170 Layer.succeed(ProjectSetupScriptRunner.ProjectSetupScriptRunner, {
131171 runForThread,
132172 }),
@@ -150,8 +190,11 @@ const makeLayer = (options?: { readonly failCreate?: boolean; readonly failSetup · 17 unmodified lines
150190 Layer.provideMerge(SqlitePersistenceMemory),
151191 ),
152192 dispatch,
193+ fetchRemote,
153194 createWorktree,
195+ projections,
154196 removeWorktree,
197+ resolveRemoteTrackingCommit,
155198 runForThread,
156199 sendToThread,
157200 };
@@ -213,6 +256,104 @@ it.effect( · 55 unmodified lines
213256 },
214257 );
215258
259+it.effect("preserves an existing worktree when launching a new thread", () => {
260+ const test = makeLayer();
261+ return Effect.gen(function* () {
262+ const service = yield* ThreadLaunch.ThreadLaunchService;
263+ const result = yield* service.launch({
264+ commandId: CommandId.make("command_launch_existing_worktree"),
265+ threadId: ThreadId.make("thread_existing_worktree"),
266+ projectId,
267+ title: "Thread",
268+ modelSelection,
269+ runtimeMode: "full-access",
270+ interactionMode: "default",
271+ workspaceStrategy: {
272+ type: "existing_worktree",
273+ worktreePath: "/repo-worktrees/existing",
274+ branch: "existing",
275+ },
276+ createdBy: "user",
277+ creationSource: "web",
278+ });
279+ assert.equal(result.projection.thread.worktreePath, "/repo-worktrees/existing");
280+ assert.equal(result.projection.thread.branch, "existing");
281+ assert.equal(test.createWorktree.mock.calls.length, 0);
282+ assert.equal(test.removeWorktree.mock.calls.length, 0);
283+ }).pipe(Effect.provide(test.layer));
284+});
285+
286+it.effect("fetches and resolves origin before provisioning an origin-based worktree", () => {
287+ const test = makeLayer();
288+ return Effect.gen(function* () {
289+ const service = yield* ThreadLaunch.ThreadLaunchService;
290+ yield* service.launch({
291+ commandId: CommandId.make("command_launch_origin_worktree"),
292+ projectId,
293+ title: "Thread",
294+ modelSelection,
295+ runtimeMode: "full-access",
296+ interactionMode: "default",
297+ workspaceStrategy: {
298+ type: "worktree",
299+ baseRef: "main",
300+ branch: "feature",
301+ startFromOrigin: true,
302+ },
303+ createdBy: "user",
304+ creationSource: "web",
305+ });
306+ assert.deepEqual(test.fetchRemote.mock.calls[0]?.[0], {
307+ cwd: "/repo",
308+ remoteName: "origin",
309+ });
310+ assert.deepEqual(test.resolveRemoteTrackingCommit.mock.calls[0]?.[0], {
311+ cwd: "/repo",
312+ refName: "main",
313+ fallbackRemoteName: "origin",
314+ });
315+ assert.deepEqual(test.createWorktree.mock.calls[0]?.[0], {
316+ cwd: "/repo",
317+ refName: "remote-main-sha",
318+ newRefName: "feature",
319+ baseRefName: "main",
320+ path: null,
321+ });
322+ }).pipe(Effect.provide(test.layer));
323+});
324+
325+it.effect("provisions a worktree for an existing empty thread before sending", () => {
326+ const test = makeLayer();
327+ const threadId = ThreadId.make("thread_existing_empty");
328+ test.projections.set(threadId, projectionFor(threadId));
329+ return Effect.gen(function* () {
330+ const service = yield* ThreadLaunch.ThreadLaunchService;
331+ yield* service.launch({
332+ commandId: CommandId.make("command_launch_existing_empty"),
333+ threadId,
334+ reuseExistingThread: true,
335+ projectId,
336+ title: "Thread",
337+ modelSelection,
338+ runtimeMode: "full-access",
339+ interactionMode: "default",
340+ workspaceStrategy: { type: "worktree", baseRef: "main", branch: "feature" },
341+ initialMessage: { text: "Implement it", attachments: [] },
342+ createdBy: "user",
343+ creationSource: "web",
344+ });
345+ const updateCommand = test.dispatch.mock.calls.find(
346+ ([command]) => command.type === "thread.metadata.update",
347+ )?.[0];
348+ assert.equal(updateCommand?.type, "thread.metadata.update");
349+ if (updateCommand?.type === "thread.metadata.update") {
350+ assert.equal(updateCommand.worktreePath, "/repo-worktrees/feature");
351+ assert.equal(updateCommand.branch, "feature");
352+ }
353+ assert.equal(test.sendToThread.mock.calls.length, 1);
354+ }).pipe(Effect.provide(test.layer));
355+});
356+
216357 it.effect("removes a new worktree and does not create a thread when setup fails", () => {
217358 const test = makeLayer({ failSetup: true });
218359 return Effect.gen(function* () {
apps/server/src/orchestration-v2/ThreadLaunchService.ts+125−31
@@ -1,6 +1,7 @@
11 import {
22 CommandId,
33 type ChatAttachment,
4+ type MessageId,
45 type ModelSelection,
56 type OrchestrationV2Actor,
67 type OrchestrationV2CreationSource,
@@ -27,20 +28,29 @@ import * as IdAllocator from "./IdAllocator.ts"; · 20 unmodified lines
2728 import * as ThreadManagement from "./ThreadManagementService.ts";
2829
2930 export type ThreadLaunchWorkspaceStrategy =
30− | { readonly type: "root" }
31+ | { readonly type: "root"; readonly branch?: string | undefined }
32+ | {
33+ readonly type: "existing_worktree";
34+ readonly worktreePath: string;
35+ readonly branch?: string | undefined;
36+ }
3137 | {
3238 readonly type: "worktree";
3339 readonly baseRef: string;
34− readonly branch?: string;
40+ readonly branch?: string | undefined;
41+ readonly startFromOrigin?: boolean | undefined;
3542 };
3643
3744 export interface ThreadLaunchInitialMessage {
45+ readonly messageId?: MessageId;
3846 readonly text: string;
3947 readonly attachments: ReadonlyArray<ChatAttachment>;
4048 }
4149
4250 export interface ThreadLaunchInput {
4351 readonly commandId: CommandId;
52+ readonly threadId?: ThreadId;
53+ readonly reuseExistingThread?: boolean;
4454 readonly projectId: ProjectId;
4555 readonly title: string;
4656 readonly modelSelection: ModelSelection;
@@ -69,6 +79,7 @@ export class ThreadLaunchError extends Schema.TaggedErrorClass<ThreadLaunchError · 22 unmodified lines
6979 "provision-worktree",
7080 "run-setup-script",
7181 "create-thread",
82+ "update-thread",
7283 "dispatch-message",
7384 "compensate-worktree",
7485 ]),
@@ -185,11 +196,18 @@ export const make = Effect.gen(function* () { · 110 unmodified lines
185196 ),
186197 );
187198 const stored = yield* loadWorkflow(input);
199+ if (input.reuseExistingThread === true && input.threadId === undefined) {
200+ return yield* mapError(
201+ input,
202+ "update-thread",
203+ )("Reusing an existing thread requires a thread id.");
204+ }
188205 const threadId =
189206 stored === null
190− ? yield* ids.allocate
207+ ? (input.threadId ??
208+ (yield* ids.allocate
191209 .thread({ projectId: input.projectId })
192− .pipe(Effect.mapError(mapError(input, "persist-workflow")))
210+ .pipe(Effect.mapError(mapError(input, "persist-workflow")))))
193211 : ThreadId.make(stored.thread_id);
194212 if (stored?.status === "completed") {
195213 return {
@@ -201,7 +219,38 @@ export const make = Effect.gen(function* () { · 5 unmodified lines
201219 };
202220 }
203221
204− let branch = stored?.branch ?? null;
222+ const validateReusableThread = Effect.fn("ThreadLaunchService.validateReusableThread")(
223+ function* () {
224+ const projection = yield* threads
225+ .getThreadProjection(threadId)
226+ .pipe(Effect.mapError(mapError(input, "update-thread", threadId)));
227+ if (
228+ projection.thread.projectId !== input.projectId ||
229+ projection.thread.archivedAt !== null ||
230+ projection.thread.deletedAt !== null ||
231+ projection.messages.length > 0 ||
232+ projection.runs.length > 0
233+ ) {
234+ return yield* mapError(
235+ input,
236+ "update-thread",
237+ threadId,
238+ )(
239+ "Only an empty active thread in the target project can change workspace during launch.",
240+ );
241+ }
242+ return projection;
243+ },
244+ );
245+ if (input.reuseExistingThread === true && stored?.thread_committed !== 1) {
246+ yield* validateReusableThread();
247+ }
248+
249+ let branch =
250+ stored?.branch ??
251+ (input.workspaceStrategy.type === "worktree"
252+ ? null
253+ : (input.workspaceStrategy.branch ?? null));
205254 let title = stored?.title ?? input.title;
206255 if (input.initialMessage !== undefined) {
207256 if (title === "New thread") {
@@ -235,15 +284,36 @@ export const make = Effect.gen(function* () { · 27 unmodified lines
235284 branch = branch ?? input.workspaceStrategy.branch ?? `thread-${threadId}`;
236285 }
237286
238− let worktreePath = stored?.worktree_path ?? null;
287+ let worktreePath =
288+ stored?.worktree_path ??
289+ (input.workspaceStrategy.type === "existing_worktree"
290+ ? input.workspaceStrategy.worktreePath
291+ : null);
292+ const ownsProvisionedWorktree = input.workspaceStrategy.type === "worktree";
239293 const threadCommitted = stored?.thread_committed === 1;
240294 let setupCommitted = stored?.setup_committed === 1;
241295 let messageCommitted = stored?.message_committed === 1;
242296 if (input.workspaceStrategy.type === "worktree" && worktreePath === null) {
297+ let startRef = input.workspaceStrategy.baseRef;
298+ if (input.workspaceStrategy.startFromOrigin === true) {
299+ yield* git
300+ .fetchRemote({ cwd: project.workspaceRoot, remoteName: "origin" })
301+ .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId)));
302+ startRef = yield* git
303+ .resolveRemoteTrackingCommit({
304+ cwd: project.workspaceRoot,
305+ refName: input.workspaceStrategy.baseRef,
306+ fallbackRemoteName: "origin",
307+ })
308+ .pipe(
309+ Effect.map((resolved) => resolved.commitSha),
310+ Effect.mapError(mapError(input, "provision-worktree", threadId)),
311+ );
312+ }
243313 const worktree = yield* git
244314 .createWorktree({
245315 cwd: project.workspaceRoot,
246− refName: input.workspaceStrategy.baseRef,
316+ refName: startRef,
247317 newRefName: branch!,
248318 baseRefName: input.workspaceStrategy.baseRef,
249319 path: null,
@@ -278,7 +348,7 @@ export const make = Effect.gen(function* () { · 28 unmodified lines
278348 }),
279349 );
280350 if (setupExit._tag === "Failure") {
281− if (worktreePath !== null && !threadCommitted) {
351+ if (ownsProvisionedWorktree && worktreePath !== null && !threadCommitted) {
282352 yield* git
283353 .removeWorktree({ cwd: project.workspaceRoot, path: worktreePath })
284354 .pipe(Effect.mapError(mapError(input, "compensate-worktree", threadId)));
@@ -313,24 +383,36 @@ export const make = Effect.gen(function* () { · 28 unmodified lines
313383 }
314384
315385 if (!threadCommitted) {
316− const createExit = yield* Effect.exit(
317− threads.dispatch({
318− type: "thread.create",
319− commandId: input.commandId,
320− threadId,
321− projectId: input.projectId,
322− title,
323− modelSelection: input.modelSelection,
324− runtimeMode: input.runtimeMode,
325− interactionMode: input.interactionMode,
326− branch,
327− worktreePath,
328− createdBy: input.createdBy,
329− creationSource: input.creationSource,
330− }),
331− );
386+ const commitThread =
387+ input.reuseExistingThread === true
388+ ? Effect.gen(function* () {
389+ yield* validateReusableThread();
390+ return yield* threads.dispatch({
391+ type: "thread.metadata.update",
392+ commandId: input.commandId,
393+ threadId,
394+ title,
395+ branch,
396+ worktreePath,
397+ });
398+ })
399+ : threads.dispatch({
400+ type: "thread.create",
401+ commandId: input.commandId,
402+ threadId,
403+ projectId: input.projectId,
404+ title,
405+ modelSelection: input.modelSelection,
406+ runtimeMode: input.runtimeMode,
407+ interactionMode: input.interactionMode,
408+ branch,
409+ worktreePath,
410+ createdBy: input.createdBy,
411+ creationSource: input.creationSource,
412+ });
413+ const createExit = yield* Effect.exit(commitThread);
332414 if (createExit._tag === "Failure") {
333− if (worktreePath !== null) {
415+ if (ownsProvisionedWorktree && worktreePath !== null) {
334416 yield* git
335417 .removeWorktree({ cwd: project.workspaceRoot, path: worktreePath })
336418 .pipe(Effect.mapError(mapError(input, "compensate-worktree", threadId)));
@@ -339,17 +421,27 @@ export const make = Effect.gen(function* () { · 2 unmodified lines
339421 }
340422 yield* saveWorkflow(input, {
341423 threadId,
342− status: "thread_create_failed",
424+ status:
425+ input.reuseExistingThread === true ? "thread_update_failed" : "thread_create_failed",
343426 title,
344427 worktreePath,
345428 branch,
346429 setupCommitted,
347430 threadCommitted: false,
348431 messageCommitted,
349− lastError: "Thread creation failed.",
432+ lastError:
433+ input.reuseExistingThread === true
434+ ? "Thread workspace update failed."
435+ : "Thread creation failed.",
350436 });
351437 return yield* Effect.failCause(createExit.cause).pipe(
352− Effect.mapError(mapError(input, "create-thread", threadId)),
438+ Effect.mapError(
439+ mapError(
440+ input,
441+ input.reuseExistingThread === true ? "update-thread" : "create-thread",
442+ threadId,
443+ ),
444+ ),
353445 );
354446 }
355447 yield* saveWorkflow(input, {
@@ -365,9 +457,11 @@ export const make = Effect.gen(function* () { · 9 unmodified lines
365457 }
366458
367459 if (input.initialMessage !== undefined && !messageCommitted) {
368− const messageId = yield* ids.allocate
369− .message({ threadId, ordinal: 1 })
370− .pipe(Effect.mapError(mapError(input, "dispatch-message", threadId)));
460+ const messageId =
461+ input.initialMessage.messageId ??
462+ (yield* ids.allocate
463+ .message({ threadId, ordinal: 1 })
464+ .pipe(Effect.mapError(mapError(input, "dispatch-message", threadId))));
371465 yield* threads
372466 .sendToThread({
373467 projectId: input.projectId,
apps/server/src/orchestration-v2/runtimeLayer.ts+11−7
@@ -32,7 +32,7 @@ import { layer as providerTurnControlServiceLayer } from "./ProviderTurnControlS · 31 unmodified lines
3232 import { layer as providerTurnStartServiceLayer } from "./ProviderTurnStartService.ts";
3333 import { layer as runExecutionServiceLayer } from "./RunExecutionService.ts";
3434 import { layer as runFinalizationServiceLayer } from "./RunFinalizationService.ts";
35−import { layer as runtimePolicyLayer } from "./RuntimePolicy.ts";
35+import { layerFromProjectRepository as runtimePolicyLayerFromProjectRepository } from "./RuntimePolicy.ts";
3636 import { layer as runtimeRequestServiceLayer } from "./RuntimeRequestService.ts";
3737 import { layer as threadManagementServiceLayer } from "./ThreadManagementService.ts";
3838 import { layer as threadLaunchServiceLayer } from "./ThreadLaunchService.ts";
@@ -40,6 +40,13 @@ import { layer as threadLifecycleServiceLayer } from "./ThreadLifecycleService.t · 1 unmodified line
4040 import { layer as threadForkServiceLayer } from "./ThreadForkService.ts";
4141 import { layer as turnItemPositionStoreLayer } from "./TurnItemPositionStore.ts";
4242
43+export const ProjectServiceLayerLive = projectServiceLayer.pipe(
44+ Layer.provide(Layer.merge(ProjectionProjectRepositoryLive, OrchestrationLayerLive)),
45+);
46+const runtimePolicyProvided = runtimePolicyLayerFromProjectRepository.pipe(
47+ Layer.provide(ProjectionProjectRepositoryLive),
48+);
49+
4350 const eventStoreProvided = eventStoreLayer.pipe(
4451 Layer.provide(OrchestrationEventInfrastructureLayerLive),
4552 );
@@ -111,7 +118,7 @@ const providerTurnStartServiceProvided = providerTurnStartServiceLayer.pipe( · 65 unmodified lines
111118 projectionStoreLayer,
112119 providerSessionManagerProvided,
113120 runExecutionServiceProvided,
114− runtimePolicyLayer,
121+ runtimePolicyProvided,
115122 ),
116123 ),
117124 );
@@ -130,7 +137,7 @@ const checkpointRollbackServiceProvided = checkpointRollbackServiceLayer.pipe( · 12 unmodified lines
130137 idAllocatorLayer,
131138 projectionStoreLayer,
132139 providerSessionManagerProvided,
133− runtimePolicyLayer,
140+ runtimePolicyProvided,
134141 ),
135142 ),
136143 );
@@ -189,7 +196,7 @@ const orchestratorProvided = orchestratorLayer.pipe( · 52 unmodified lines
189196 idAllocatorLayer,
190197 providerAdapterRegistryProvided,
191198 providerEventIngestorProvided,
192− runtimePolicyLayer,
199+ runtimePolicyProvided,
193200 providerSessionManagerProvided,
194201 providerSwitchServiceProvided,
195202 runExecutionServiceProvided,
@@ -201,9 +208,6 @@ const orchestratorProvided = orchestratorLayer.pipe( · 5 unmodified lines
201208 const threadManagementProvided = threadManagementServiceLayer.pipe(
202209 Layer.provide(orchestratorProvided),
203210 );
204−export const ProjectServiceLayerLive = projectServiceLayer.pipe(
205− Layer.provide(Layer.merge(ProjectionProjectRepositoryLive, OrchestrationLayerLive)),
206−);
207211 export const ProjectSetupScriptRunnerLayerLive = projectSetupScriptRunnerLayer.pipe(
208212 Layer.provide(ProjectServiceLayerLive),
209213 );
apps/server/src/ws.ts+199−28
@@ -1,8 +1,11 @@
11 import * as DateTime from "effect/DateTime";
22 import * as Duration from "effect/Duration";
3+import * as Encoding from "effect/Encoding";
34 import * as Effect from "effect/Effect";
5+import * as FileSystem from "effect/FileSystem";
46 import * as Layer from "effect/Layer";
57 import * as Option from "effect/Option";
8+import * as Path from "effect/Path";
69 import * as Queue from "effect/Queue";
710 import * as Ref from "effect/Ref";
811 import * as Result from "effect/Result";
@@ -13,9 +16,11 @@ import { · 4 unmodified lines
1316 type AuthAccessStreamEvent,
1417 type AuthEnvironmentScope,
1518 AuthSessionId,
19+ CommandId,
1620 type DiscoveredLocalServerList,
1721 type GitActionProgressEvent,
1822 type GitManagerServiceError,
23+ type MessageId,
1924 OrchestrationGetFullThreadDiffError,
2025 OrchestrationGetTurnDiffError,
2126 ORCHESTRATION_V2_WS_METHODS,
@@ -28,18 +33,21 @@ import { · 6 unmodified lines
2833 type ProjectEntriesFailure,
2934 type ProjectFileFailure,
3035 type ProjectFileOperation,
36+ type ProjectMutation,
3137 ProjectListEntriesError,
3238 ProjectReadFileError,
3339 ProjectSearchContentsError,
3440 ProjectSearchEntriesError,
3541 ProjectWriteFileError,
42+ ProjectMutationError,
3643 RelayClientInstallFailedError,
3744 type RelayClientInstallProgressEvent,
3845 type FilesystemBrowseFailure,
3946 FilesystemBrowseError,
4047 AssetWorkspaceContextNotFoundError,
4148 AssetWorkspaceContextResolutionError,
42− RpcClientId,
49+ ChatAttachmentId,
50+ PersistChatAttachmentsError,
4351 EnvironmentAuthorizationError,
4452 ThreadId,
4553 type TerminalAttachStreamEvent,
@@ -81,6 +89,8 @@ import * as TerminalManager from "./terminal/Manager.ts"; · 35 unmodified lines
8189 import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";
8290 import * as PreviewManager from "./preview/Manager.ts";
8391 import { issueAssetUrl } from "./assets/AssetAccess.ts";
92+import { attachmentRelativePath, createDeterministicAttachmentId } from "./attachmentStore.ts";
93+import { parseBase64DataUrl } from "./imageMime.ts";
8494 import * as PortScanner from "./preview/PortScanner.ts";
8595 import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
8696 import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
@@ -118,6 +128,73 @@ function unexpectedCompatibilityError(error: never): never { · 31 unmodified lines
118128 throw new Error(`Unhandled compatibility error: ${String(error)}`);
119129 }
120130
131+const persistChatAttachments = Effect.fn("ws.assets.persistChatAttachments")(function* (input: {
132+ readonly threadId: ThreadId;
133+ readonly messageId: MessageId;
134+ readonly attachments: ReadonlyArray<{
135+ readonly type: "image";
136+ readonly name: string;
137+ readonly mimeType: string;
138+ readonly sizeBytes: number;
139+ readonly dataUrl: string;
140+ }>;
141+}) {
142+ const config = yield* ServerConfig.ServerConfig;
143+ const fileSystem = yield* FileSystem.FileSystem;
144+ const path = yield* Path.Path;
145+ return yield* Effect.forEach(
146+ input.attachments.map((attachment, index) => ({ attachment, index })),
147+ Effect.fn("ws.assets.persistChatAttachment")(function* ({ attachment, index }) {
148+ const parsed = parseBase64DataUrl(attachment.dataUrl);
149+ if (parsed === null || parsed.mimeType !== attachment.mimeType.toLowerCase()) {
150+ return yield* new PersistChatAttachmentsError({
151+ message: `Attachment ${attachment.name} has an invalid image payload.`,
152+ });
153+ }
154+ const bytes = yield* Effect.fromResult(Encoding.decodeBase64(parsed.base64)).pipe(
155+ Effect.mapError(
156+ (cause) =>
157+ new PersistChatAttachmentsError({
158+ message: `Attachment ${attachment.name} is not valid base64.`,
159+ cause,
160+ }),
161+ ),
162+ );
163+ if (bytes.byteLength !== attachment.sizeBytes) {
164+ return yield* new PersistChatAttachmentsError({
165+ message: `Attachment ${attachment.name} size does not match its payload.`,
166+ });
167+ }
168+ const rawId = createDeterministicAttachmentId(input.threadId, `${input.messageId}:${index}`);
169+ if (rawId === null) {
170+ return yield* new PersistChatAttachmentsError({
171+ message: "Could not allocate an attachment identifier.",
172+ });
173+ }
174+ const persisted = {
175+ type: "image" as const,
176+ id: ChatAttachmentId.make(rawId),
177+ name: attachment.name,
178+ mimeType: attachment.mimeType,
179+ sizeBytes: attachment.sizeBytes,
180+ };
181+ yield* fileSystem
182+ .writeFile(path.join(config.attachmentsDir, attachmentRelativePath(persisted)), bytes)
183+ .pipe(
184+ Effect.mapError(
185+ (cause) =>
186+ new PersistChatAttachmentsError({
187+ message: `Could not persist attachment ${attachment.name}.`,
188+ cause,
189+ }),
190+ ),
191+ );
192+ return persisted;
193+ }),
194+ { concurrency: 2 },
195+ );
196+});
197+
121198 function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesError): {
122199 readonly failure: ProjectEntriesFailure;
123200 readonly normalizedCwd?: string;
@@ -253,8 +330,11 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ · 129 unmodified lines
253330 [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope],
254331 [WS_METHODS.projectsSearchEntries, AuthOrchestrationReadScope],
255332 [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope],
333+ [WS_METHODS.projectsMutate, AuthOrchestrationOperateScope],
256334 [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope],
257335 [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope],
336+ [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope],
337+ [WS_METHODS.assetsPersistChatAttachments, AuthOrchestrationOperateScope],
258338 [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope],
259339 [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope],
260340 [WS_METHODS.vcsPull, AuthOrchestrationOperateScope],
@@ -623,20 +703,28 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 362 unmodified lines
623703 },
624704 );
625705
626− const getOrchestrationV2ArchivedShellSnapshot = threadManagement.getShellSnapshot().pipe(
627− Effect.map((snapshot) => ({
628− schemaVersion: snapshot.schemaVersion,
629− snapshotSequence: snapshot.snapshotSequence,
630− threads: snapshot.archivedThreads,
631− })),
632− Effect.mapError(
633− (cause) =>
634− new OrchestrationV2GetShellSnapshotError({
635− message: "Failed to load archived thread snapshot",
636− cause,
637− }),
638− ),
639− );
706+ const getOrchestrationV2ArchivedShellSnapshot = sql
707+ .withTransaction(
708+ Effect.gen(function* () {
709+ const projects = yield* projectionSnapshotQuery.getShellSnapshot();
710+ const threads = yield* threadManagement.getShellSnapshot();
711+ return {
712+ schemaVersion: threads.schemaVersion,
713+ snapshotSequence: yield* applicationEvents.latestApplicationSequence,
714+ projects: projects.projects,
715+ threads: threads.archivedThreads,
716+ } as const;
717+ }),
718+ )
719+ .pipe(
720+ Effect.mapError(
721+ (cause) =>
722+ new OrchestrationV2GetShellSnapshotError({
723+ message: "Failed to load archived thread snapshot",
724+ cause,
725+ }),
726+ ),
727+ );
640728
641729 const subscribeOrchestrationV2ArchivedShell = Effect.fn(
642730 "ws.orchestrationV2.subscribeArchivedShell",
@@ -671,6 +759,64 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 28 unmodified lines
671759 return Stream.concat(Stream.make({ kind: "snapshot" as const, snapshot }), live);
672760 });
673761
762+ const mutateProject = Effect.fn("ws.projects.mutate")(function* (mutation: ProjectMutation) {
763+ switch (mutation.type) {
764+ case "project.create":
765+ return yield* projectService.create({
766+ commandId: mutation.commandId,
767+ projectId: mutation.projectId,
768+ title: mutation.title,
769+ workspaceRoot: mutation.workspaceRoot,
770+ ...(mutation.createWorkspaceRootIfMissing === undefined
771+ ? {}
772+ : { createWorkspaceRootIfMissing: mutation.createWorkspaceRootIfMissing }),
773+ ...(mutation.defaultModelSelection === undefined
774+ ? {}
775+ : { defaultModelSelection: mutation.defaultModelSelection }),
776+ ...(mutation.scripts === undefined ? {} : { scripts: mutation.scripts }),
777+ });
778+ case "project.update":
779+ return yield* projectService.update({
780+ commandId: mutation.commandId,
781+ projectId: mutation.projectId,
782+ ...(mutation.title === undefined ? {} : { title: mutation.title }),
783+ ...(mutation.workspaceRoot === undefined
784+ ? {}
785+ : { workspaceRoot: mutation.workspaceRoot }),
786+ ...(mutation.defaultModelSelection === undefined
787+ ? {}
788+ : { defaultModelSelection: mutation.defaultModelSelection }),
789+ ...(mutation.scripts === undefined ? {} : { scripts: mutation.scripts }),
790+ });
791+ case "project.delete": {
792+ const snapshot = yield* threadManagement.getShellSnapshot();
793+ const projectThreads = [...snapshot.threads, ...snapshot.archivedThreads].filter(
794+ (thread) => thread.projectId === mutation.projectId,
795+ );
796+ if (projectThreads.length > 0 && mutation.force !== true) {
797+ return yield* new ProjectMutationError({
798+ commandId: mutation.commandId,
799+ message: `Project ${mutation.projectId} is not empty.`,
800+ });
801+ }
802+ yield* Effect.forEach(
803+ projectThreads,
804+ (thread) =>
805+ threadManagement.dispatch({
806+ type: "thread.delete",
807+ commandId: CommandId.make(`${mutation.commandId}:delete-thread:${thread.id}`),
808+ threadId: thread.id,
809+ }),
810+ { concurrency: 1, discard: true },
811+ );
812+ return yield* projectService.delete({
813+ commandId: mutation.commandId,
814+ projectId: mutation.projectId,
815+ });
816+ }
817+ }
818+ });
819+
674820 const handlers = ServerWsRpcGroup.of({
675821 [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command) =>
676822 observeRpcEffect(
@@ -680,7 +826,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 3 unmodified lines
680826 threadManagement.dispatch(
681827 ThreadManagementService.withCreationProvenance(command, {
682828 createdBy: "user",
683− creationSource: "web",
829+ creationSource: "creationSource" in command ? command.creationSource : "web",
684830 }),
685831 ),
686832 )
@@ -771,26 +917,29 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 84 unmodified lines
771917 .enqueueCommand(
772918 threadLaunch.launch({
773919 commandId: input.commandId,
920+ ...(input.threadId === undefined ? {} : { threadId: input.threadId }),
921+ ...(input.reuseExistingThread === undefined
922+ ? {}
923+ : { reuseExistingThread: input.reuseExistingThread }),
774924 projectId: input.projectId,
775925 title: input.title,
776926 modelSelection: input.modelSelection,
777927 runtimeMode: input.runtimeMode,
778928 interactionMode: input.interactionMode,
779− workspaceStrategy:
780− input.workspaceStrategy.type === "root"
781− ? { type: "root" }
782− : {
783− type: "worktree",
784− baseRef: input.workspaceStrategy.baseRef,
785− ...(input.workspaceStrategy.branch === undefined
786− ? {}
787− : { branch: input.workspaceStrategy.branch }),
788− },
929+ workspaceStrategy: input.workspaceStrategy,
789930 ...(input.initialMessage === undefined
790931 ? {}
791− : { initialMessage: input.initialMessage }),
932+ : {
933+ initialMessage: {
934+ ...(input.initialMessage.messageId === undefined
935+ ? {}
936+ : { messageId: input.initialMessage.messageId }),
937+ text: input.initialMessage.text,
938+ attachments: input.initialMessage.attachments,
939+ },
940+ }),
792941 createdBy: "user",
793− creationSource: "web",
942+ creationSource: input.creationSource ?? "web",
794943 }),
795944 )
796945 .pipe(
@@ -1137,6 +1286,22 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 340 unmodified lines
11371286 ),
11381287 { "rpc.aggregate": "workspace" },
11391288 ),
1289+ [WS_METHODS.projectsMutate]: (mutation) =>
1290+ observeRpcEffect(
1291+ WS_METHODS.projectsMutate,
1292+ startup.enqueueCommand(mutateProject(mutation)).pipe(
1293+ Effect.mapError((cause) =>
1294+ cause._tag === "ProjectMutationError"
1295+ ? cause
1296+ : new ProjectMutationError({
1297+ commandId: mutation.commandId,
1298+ message: "Failed to mutate project.",
1299+ cause,
1300+ }),
1301+ ),
1302+ ),
1303+ { "rpc.aggregate": "orchestration" },
1304+ ),
11401305 [WS_METHODS.shellOpenInEditor]: (input) =>
11411306 observeRpcEffect(WS_METHODS.shellOpenInEditor, externalLauncher.launchEditor(input), {
11421307 "rpc.aggregate": "workspace",
@@ -1195,6 +1360,12 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 52 unmodified lines
11951360 }),
11961361 { "rpc.aggregate": "workspace" },
11971362 ),
1363+ [WS_METHODS.assetsPersistChatAttachments]: (input) =>
1364+ observeRpcEffect(
1365+ WS_METHODS.assetsPersistChatAttachments,
1366+ persistChatAttachments(input).pipe(Effect.map((attachments) => ({ attachments }))),
1367+ { "rpc.aggregate": "orchestration" },
1368+ ),
11981369 [WS_METHODS.subscribeVcsStatus]: (input) =>
11991370 observeRpcStream(
12001371 WS_METHODS.subscribeVcsStatus,
apps/web/src/components/BranchToolbarBranchSelector.tsx+2−0
@@ -116,6 +116,8 @@ export function BranchToolbarBranchSelector({ · 115 unmodified lines
116116 () => scopeThreadRef(environmentId, threadId),
117117 [environmentId, threadId],
118118 );
119+ const serverThread = useThread(threadRef);
120+ const serverSession = serverThread?.runtime ?? null;
119121 const draftThread = useComposerDraftStore((store) =>
120122 draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef),
121123 );
apps/web/src/components/ChatView.logic.test.ts+29−44
@@ -1,14 +1,8 @@
1−import {
2− EnvironmentId,
3− MessageId,
4− ProjectId,
5− ProviderInstanceId,
6− ThreadId,
7− TurnId,
8−} from "@t3tools/contracts";
1+import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, RunId } from "@t3tools/contracts";
92 import { describe, expect, it } from "vite-plus/test";
103
11−import type { Thread, ThreadShell } from "../types";
4+import type { Thread } from "../types";
5+import { makeThreadFixture } from "../test-fixtures";
126 import {
137 MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
148 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
@@ -37,7 +31,7 @@ const threadId = ThreadId.make("thread-1"); · 22 unmodified lines
3731 const now = "2026-03-29T00:00:00.000Z";
3832
3933 function makeThread(overrides: Partial<Thread> = {}): Thread {
40− return {
34+ return makeThreadFixture({
4135 id: threadId,
4236 environmentId,
4337 projectId,
@@ -48,27 +42,25 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 4 unmodified lines
4842 },
4943 runtimeMode: "full-access",
5044 interactionMode: "default",
51− session: null,
45+ runtime: null,
5246 messages: [],
5347 proposedPlans: [],
54− activities: [],
55− checkpoints: [],
5648 createdAt: now,
5749 updatedAt: now,
5850 archivedAt: null,
5951 settledOverride: null,
6052 settledAt: null,
6153 deletedAt: null,
62− latestTurn: null,
54+ latestRun: null,
6355 branch: null,
6456 worktreePath: null,
6557 ...overrides,
66− };
58+ });
6759 }
6860
6961 const completedTurn = {
70− turnId: TurnId.make("turn-1"),
71− state: "completed" as const,
62+ runId: RunId.make("turn-1"),
63+ status: "completed" as const,
7264 requestedAt: now,
7365 startedAt: "2026-03-29T00:00:01.000Z",
7466 completedAt: "2026-03-29T00:00:10.000Z",
@@ -76,12 +68,10 @@ const completedTurn = { · 1 unmodified line
7668 };
7769
7870 const readySession = {
79− threadId,
80− status: "ready" as const,
71+ status: "completed" as const,
8172 providerName: "codex",
8273 providerInstanceId: ProviderInstanceId.make("codex"),
83− runtimeMode: "full-access" as const,
84− activeTurnId: null,
74+ activeRunId: null,
8575 lastError: null,
8676 updatedAt: "2026-03-29T00:00:10.000Z",
8777 };
@@ -527,16 +517,15 @@ describe("startNewThreadForProject", () => { · 439 unmodified lines
527517 describe("hasServerAcknowledgedLocalDispatch", () => {
528518 it("does not acknowledge unchanged server state", () => {
529519 const localDispatch = createLocalDispatchSnapshot(
530− makeThread({ latestTurn: completedTurn, session: readySession }),
520+ makeThread({ latestRun: completedTurn, runtime: readySession }),
531521 );
532522
533523 expect(
534524 hasServerAcknowledgedLocalDispatch({
535525 localDispatch,
536526 phase: "ready",
537− latestTurn: completedTurn,
538− latestUserMessageId: localDispatch.latestUserMessageId,
539− session: readySession,
527+ latestRun: completedTurn,
528+ runtime: readySession,
540529 hasPendingApproval: false,
541530 hasPendingUserInput: false,
542531 threadError: null,
@@ -546,11 +535,11 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 3 unmodified lines
546535
547536 it("acknowledges a settled newer turn", () => {
548537 const localDispatch = createLocalDispatchSnapshot(
549− makeThread({ latestTurn: completedTurn, session: readySession }),
538+ makeThread({ latestRun: completedTurn, runtime: readySession }),
550539 );
551540 const newerTurn = {
552541 ...completedTurn,
553− turnId: TurnId.make("turn-2"),
542+ runId: RunId.make("turn-2"),
554543 requestedAt: "2026-03-29T00:01:00.000Z",
555544 startedAt: "2026-03-29T00:01:01.000Z",
556545 completedAt: "2026-03-29T00:01:30.000Z",
@@ -560,9 +549,8 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 3 unmodified lines
560549 hasServerAcknowledgedLocalDispatch({
561550 localDispatch,
562551 phase: "ready",
563− latestTurn: newerTurn,
564− latestUserMessageId: localDispatch.latestUserMessageId,
565− session: { ...readySession, updatedAt: newerTurn.completedAt },
552+ latestRun: newerTurn,
553+ runtime: { ...readySession, updatedAt: newerTurn.completedAt },
566554 hasPendingApproval: false,
567555 hasPendingUserInput: false,
568556 threadError: null,
@@ -572,12 +560,12 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 3 unmodified lines
572560
573561 it("waits for the matching running turn before acknowledging", () => {
574562 const localDispatch = createLocalDispatchSnapshot(
575− makeThread({ latestTurn: completedTurn, session: readySession }),
563+ makeThread({ latestRun: completedTurn, runtime: readySession }),
576564 );
577565 const runningTurn = {
578566 ...completedTurn,
579− turnId: TurnId.make("turn-2"),
580− state: "running" as const,
567+ runId: RunId.make("turn-2"),
568+ status: "running" as const,
581569 requestedAt: "2026-03-29T00:01:00.000Z",
582570 startedAt: "2026-03-29T00:01:01.000Z",
583571 completedAt: null,
@@ -587,12 +575,11 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 3 unmodified lines
587575 hasServerAcknowledgedLocalDispatch({
588576 localDispatch,
589577 phase: "running",
590− latestTurn: runningTurn,
591− latestUserMessageId: localDispatch.latestUserMessageId,
592− session: {
578+ latestRun: runningTurn,
579+ runtime: {
593580 ...readySession,
594581 status: "running",
595− activeTurnId: TurnId.make("turn-other"),
582+ activeRunId: RunId.make("turn-other"),
596583 },
597584 hasPendingApproval: false,
598585 hasPendingUserInput: false,
@@ -603,12 +590,11 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 4 unmodified lines
603590 hasServerAcknowledgedLocalDispatch({
604591 localDispatch,
605592 phase: "running",
606− latestTurn: runningTurn,
607− latestUserMessageId: localDispatch.latestUserMessageId,
608− session: {
593+ latestRun: runningTurn,
594+ runtime: {
609595 ...readySession,
610596 status: "running",
611− activeTurnId: runningTurn.turnId,
597+ activeRunId: runningTurn.runId,
612598 },
613599 hasPendingApproval: false,
614600 hasPendingUserInput: false,
@@ -665,9 +651,8 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 50 unmodified lines
665651 const common = {
666652 localDispatch,
667653 phase: "ready" as const,
668− latestTurn: null,
669− latestUserMessageId: localDispatch.latestUserMessageId,
670− session: null,
654+ latestRun: null,
655+ runtime: null,
671656 hasPendingApproval: false,
672657 hasPendingUserInput: false,
673658 threadError: null,
apps/web/src/components/ChatView.logic.ts+75−69
@@ -8,9 +8,11 @@ import { · 7 unmodified lines
88 type ScopedProjectRef,
99 type ScopedThreadRef,
1010 type ThreadId,
11− type TurnId,
11+ type RunId,
1212 } from "@t3tools/contracts";
13−import { type ChatMessage, type SessionPhase, type Thread, type ThreadShell } from "../types";
13+import * as DateTime from "effect/DateTime";
14+import { presentThread } from "@t3tools/client-runtime/state/shell";
15+import { type ChatMessage, type SessionPhase, type Thread } from "../types";
1416 import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
1517 import * as Schema from "effect/Schema";
1618 import { appAtomRegistry } from "../rpc/atomRegistry";
@@ -70,29 +72,46 @@ export function buildLocalDraftThread( · 53 unmodified lines
7072 draftThread: DraftThreadState,
7173 fallbackModelSelection: ModelSelection,
7274 ): Thread {
73− return {
74− id: threadId,
75− environmentId: draftThread.environmentId,
76− projectId: draftThread.projectId,
77− title: "New thread",
78− modelSelection: fallbackModelSelection,
79− runtimeMode: draftThread.runtimeMode,
80− interactionMode: draftThread.interactionMode,
81− session: null,
75+ const timestamp = DateTime.makeUnsafe(draftThread.createdAt);
76+ return presentThread(draftThread.environmentId, {
77+ thread: {
78+ id: threadId,
79+ projectId: draftThread.projectId,
80+ title: "New thread",
81+ providerInstanceId: fallbackModelSelection.instanceId,
82+ modelSelection: fallbackModelSelection,
83+ runtimeMode: draftThread.runtimeMode,
84+ interactionMode: draftThread.interactionMode,
85+ branch: draftThread.branch,
86+ worktreePath: draftThread.worktreePath,
87+ activeProviderThreadId: null,
88+ lineage: { rootThreadId: threadId, parentThreadId: null, relationshipToParent: null },
89+ forkedFrom: null,
90+ createdBy: "user",
91+ creationSource: "web",
92+ createdAt: timestamp,
93+ updatedAt: timestamp,
94+ archivedAt: null,
95+ deletedAt: null,
96+ },
97+ runs: [],
98+ attempts: [],
99+ nodes: [],
100+ subagents: [],
101+ providerSessions: [],
102+ providerThreads: [],
103+ providerTurns: [],
104+ runtimeRequests: [],
82105 messages: [],
83− createdAt: draftThread.createdAt,
84− updatedAt: draftThread.createdAt,
85− archivedAt: null,
86− settledOverride: null,
87− settledAt: null,
88− deletedAt: null,
89− latestTurn: null,
90− branch: draftThread.branch,
91− worktreePath: draftThread.worktreePath,
106+ plans: [],
107+ turnItems: [],
108+ checkpointScopes: [],
92109 checkpoints: [],
93− activities: [],
94− proposedPlans: [],
95− };
110+ contextHandoffs: [],
111+ contextTransfers: [],
112+ visibleTurnItems: [],
113+ updatedAt: timestamp,
114+ });
96115 }
97116
98117 export function buildLoadingThreadFromShell(shell: ThreadShell): Thread {
@@ -353,7 +372,7 @@ export function isBranchMismatchDismissedForSession(key: string | null): boolean · 254 unmodified lines
353372
354373 export function threadHasStarted(thread: Thread | null | undefined): boolean {
355374 return Boolean(
356− thread && (thread.latestTurn !== null || thread.messages.length > 0 || thread.session !== null),
375+ thread && (thread.latestRun !== null || thread.messages.length > 0 || thread.runtime !== null),
357376 );
358377 }
359378
@@ -377,7 +396,7 @@ export function deriveLockedProvider(input: { · 17 unmodified lines
377396 if (!threadHasStarted(input.thread)) {
378397 return null;
379398 }
380− const sessionProvider = input.thread?.session?.providerName ?? null;
399+ const sessionProvider = input.thread?.runtime?.providerName ?? null;
381400 if (sessionProvider && isProviderDriverKind(sessionProvider)) {
382401 return sessionProvider;
383402 }
@@ -478,41 +497,37 @@ export async function waitForStartedServerThread( · 94 unmodified lines
478497 export interface LocalDispatchSnapshot {
479498 startedAt: string;
480499 preparingWorktree: boolean;
481− latestUserMessageId: ChatMessage["id"] | null;
482− latestTurnTurnId: TurnId | null;
483− latestTurnRequestedAt: string | null;
484− latestTurnStartedAt: string | null;
485− latestTurnCompletedAt: string | null;
486− sessionStatus: NonNullable<Thread["session"]>["status"] | null;
487− sessionUpdatedAt: string | null;
500+ latestRunId: RunId | null;
501+ latestRunRequestedAt: string | null;
502+ latestRunStartedAt: string | null;
503+ latestRunCompletedAt: string | null;
504+ runtimeStatus: NonNullable<Thread["runtime"]>["status"] | null;
505+ runtimeUpdatedAt: string | null;
488506 }
489507
490508 export function createLocalDispatchSnapshot(
491509 activeThread: Thread | undefined,
492510 options?: { preparingWorktree?: boolean },
493511 ): LocalDispatchSnapshot {
494− const latestTurn = activeThread?.latestTurn ?? null;
495− const session = activeThread?.session ?? null;
496− const latestUserMessage = activeThread?.messages.findLast((message) => message.role === "user");
512+ const latestRun = activeThread?.latestRun ?? null;
513+ const runtime = activeThread?.runtime ?? null;
497514 return {
498515 startedAt: new Date().toISOString(),
499516 preparingWorktree: Boolean(options?.preparingWorktree),
500− latestUserMessageId: latestUserMessage?.id ?? null,
501− latestTurnTurnId: latestTurn?.turnId ?? null,
502− latestTurnRequestedAt: latestTurn?.requestedAt ?? null,
503− latestTurnStartedAt: latestTurn?.startedAt ?? null,
504− latestTurnCompletedAt: latestTurn?.completedAt ?? null,
505− sessionStatus: session?.status ?? null,
506− sessionUpdatedAt: session?.updatedAt ?? null,
517+ latestRunId: latestRun?.runId ?? null,
518+ latestRunRequestedAt: latestRun?.requestedAt ?? null,
519+ latestRunStartedAt: latestRun?.startedAt ?? null,
520+ latestRunCompletedAt: latestRun?.completedAt ?? null,
521+ runtimeStatus: runtime?.status ?? null,
522+ runtimeUpdatedAt: runtime?.updatedAt ?? null,
507523 };
508524 }
509525
510526 export function hasServerAcknowledgedLocalDispatch(input: {
511527 localDispatch: LocalDispatchSnapshot | null;
512528 phase: SessionPhase;
513− latestTurn: Thread["latestTurn"] | null;
514− latestUserMessageId: ChatMessage["id"] | null;
515− session: Thread["session"] | null;
529+ latestRun: Thread["latestRun"] | null;
530+ runtime: Thread["runtime"] | null;
516531 hasPendingApproval: boolean;
517532 hasPendingUserInput: boolean;
518533 threadError: string | null | undefined;
@@ -524,34 +539,25 @@ export function hasServerAcknowledgedLocalDispatch(input: { · 5 unmodified lines
524539 return true;
525540 }
526541
527− const latestTurn = input.latestTurn ?? null;
528− const session = input.session ?? null;
529− const latestUserMessageChanged =
530− input.localDispatch.latestUserMessageId !== input.latestUserMessageId;
531− const latestTurnChanged =
532− input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) ||
533− input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) ||
534− input.localDispatch.latestTurnStartedAt !== (latestTurn?.startedAt ?? null) ||
535− input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null);
542+ const latestRun = input.latestRun ?? null;
543+ const runtime = input.runtime ?? null;
544+ const latestRunChanged =
545+ input.localDispatch.latestRunId !== (latestRun?.runId ?? null) ||
546+ input.localDispatch.latestRunRequestedAt !== (latestRun?.requestedAt ?? null) ||
547+ input.localDispatch.latestRunStartedAt !== (latestRun?.startedAt ?? null) ||
548+ input.localDispatch.latestRunCompletedAt !== (latestRun?.completedAt ?? null);
536549
537550 if (input.phase === "running") {
538− // Steering adds a user message to the current running turn without
539− // necessarily changing any of the turn timestamps. Treat that projected
540− // message as the server acknowledgment so the composer does not remain
541− // stuck in its local "Sending" state until the turn settles.
542− if (latestUserMessageChanged) {
543− return true;
544− }
545− if (!latestTurnChanged) {
551+ if (!latestRunChanged) {
546552 return false;
547553 }
548− if (latestTurn?.startedAt === null || latestTurn === null) {
554+ if (latestRun?.startedAt === null || latestRun === null) {
549555 return false;
550556 }
551557 if (
552− session?.activeTurnId !== null &&
553− session?.activeTurnId !== undefined &&
554− latestTurn?.turnId !== session.activeTurnId
558+ runtime?.activeRunId !== null &&
559+ runtime?.activeRunId !== undefined &&
560+ latestRun?.runId !== runtime.activeRunId
555561 ) {
556562 return false;
557563 }
@@ -559,8 +565,8 @@ export function hasServerAcknowledgedLocalDispatch(input: { · 1 unmodified line
559565 }
560566
561567 return (
562− latestTurnChanged ||
563− input.localDispatch.sessionStatus !== (session?.status ?? null) ||
564− input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null)
568+ latestRunChanged ||
569+ input.localDispatch.runtimeStatus !== (runtime?.status ?? null) ||
570+ input.localDispatch.runtimeUpdatedAt !== (runtime?.updatedAt ?? null)
565571 );
566572 }
apps/web/src/components/ChatView.tsx+194−267
@@ -1,5 +1,4 @@
11 import {
2− type ApprovalRequestId,
32 DEFAULT_MODEL,
43 defaultInstanceIdForDriver,
54 type EnvironmentId,
@@ -13,14 +12,15 @@ import { · 7 unmodified lines
1312 type ResolvedKeybindingsConfig,
1413 type ScopedThreadRef,
1514 type ThreadId,
16− type TurnId,
15+ type RunId,
16+ type RuntimeRequestId,
1717 type KeybindingCommand,
18− OrchestrationThreadActivity,
1918 ProviderInteractionMode,
2019 ProviderDriverKind,
2120 RuntimeMode,
2221 TerminalOpenInput,
2322 } from "@t3tools/contracts";
23+import type { ThreadWorkEntry } from "@t3tools/client-runtime/state/shell";
2424 import {
2525 connectionStatusTitle,
2626 type EnvironmentConnectionPresentation,
@@ -78,13 +78,14 @@ import { · 51 unmodified lines
7878 derivePendingUserInputs,
7979 derivePhase,
8080 deriveTimelineEntries,
81+ deriveTimelineEntriesFromVisibleTurnItems,
8182 deriveActiveWorkStartedAt,
8283 deriveActivePlanState,
8384 findSidebarProposedPlan,
8485 findLatestProposedPlan,
8586 deriveWorkLogEntries,
8687 hasActionableProposedPlan,
87− isLatestTurnSettled,
88+ isLatestRunSettled,
8889 } from "../session-logic";
8990 import { type LegendListRef } from "@legendapp/list/react";
9091 import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./chat/timelineScrollAnchoring";
@@ -217,7 +218,7 @@ import { · 126 unmodified lines
217218 useThread,
218219 useThreadProposedPlans,
219220 useThreadRefs,
220− useThreadShell,
221+ useThreadVisibleTurnItems,
221222 } from "../state/entities";
222223 import { environmentShell } from "../state/shell";
223224 import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";
@@ -309,7 +310,7 @@ import { useAssetUrls } from "../assets/assetUrls"; · 85 unmodified lines
309310
310311 const IMAGE_ONLY_BOOTSTRAP_PROMPT =
311312 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
312−const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
313+const EMPTY_WORK_ENTRIES: ThreadWorkEntry[] = [];
313314 const EMPTY_PROVIDERS: ServerProvider[] = [];
314315 const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
315316 const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
@@ -494,10 +495,10 @@ type PersistentTerminalLaunchContext = Pick<TerminalLaunchContext, "cwd" | "work · 178 unmodified lines
494495
495496 function useLocalDispatchState(input: {
496497 activeThread: Thread | undefined;
497− activeLatestTurn: Thread["latestTurn"] | null;
498+ activeLatestRun: Thread["latestRun"] | null;
498499 phase: SessionPhase;
499− activePendingApproval: ApprovalRequestId | null;
500− activePendingUserInput: ApprovalRequestId | null;
500+ activePendingApproval: RuntimeRequestId | null;
501+ activePendingUserInput: RuntimeRequestId | null;
501502 threadError: string | null | undefined;
502503 }) {
503504 const [localDispatch, setLocalDispatch] = useState<LocalDispatchSnapshot | null>(null);
@@ -513,18 +514,17 @@ function useLocalDispatchState(input: { · 9 unmodified lines
513514 hasServerAcknowledgedLocalDispatch({
514515 localDispatch,
515516 phase: input.phase,
516− latestTurn: input.activeLatestTurn,
517− latestUserMessageId,
518− session: input.activeThread?.session ?? null,
517+ latestRun: input.activeLatestRun,
518+ runtime: input.activeThread?.runtime ?? null,
519519 hasPendingApproval: input.activePendingApproval !== null,
520520 hasPendingUserInput: input.activePendingUserInput !== null,
521521 threadError: input.threadError,
522522 }),
523523 [
524− input.activeLatestTurn,
524+ input.activeLatestRun,
525525 input.activePendingApproval,
526526 input.activePendingUserInput,
527− input.activeThread?.session,
527+ input.activeThread?.runtime,
528528 input.phase,
529529 input.threadError,
530530 latestUserMessageId,
@@ -1202,23 +1202,10 @@ function ChatViewContent(props: ChatViewProps) { · 671 unmodified lines
12021202 );
12031203 const composerDraftTarget: ScopedThreadRef | DraftId =
12041204 routeKind === "server" ? routeThreadRef : props.draftId;
1205− const draftThread = useComposerDraftStore((store) =>
1206− routeKind === "server"
1207− ? store.getDraftSessionByRef(routeThreadRef)
1208− : draftId
1209− ? store.getDraftSession(draftId)
1210− : null,
1211− );
1212− const routeServerThreadShell = useThreadShell(routeKind === "server" ? routeThreadRef : null);
1213− const serverThread = useThread(routeThreadRef, { waitForShell: draftThread !== null });
1214− const loadingServerThread = useMemo(
1215− () =>
1216− threadDetailLoading && routeServerThreadShell
1217− ? buildLoadingThreadFromShell(routeServerThreadShell)
1218− : null,
1219− [routeServerThreadShell, threadDetailLoading],
1205+ const serverThread = useThread(routeKind === "server" ? routeThreadRef : null);
1206+ const serverVisibleTurnItems = useThreadVisibleTurnItems(
1207+ routeKind === "server" ? routeThreadRef : null,
12201208 );
1221− const activeServerThread = serverThread ?? loadingServerThread;
12221209 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
12231210 const activeThreadLastVisitedAt = useUiStateStore(
12241211 (store) => store.threadLastVisitedAtById[routeThreadKey],
@@ -1293,9 +1280,9 @@ function ChatViewContent(props: ChatViewProps) { · 68 unmodified lines
12931280 const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState<string | null>(
12941281 null,
12951282 );
1296− const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
1283+ const [respondingRequestIds, setRespondingRequestIds] = useState<RuntimeRequestId[]>([]);
12971284 const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState<
1298− ApprovalRequestId[]
1285+ RuntimeRequestId[]
12991286 >([]);
13001287 const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState<
13011288 Record<string, Record<string, PendingUserInputDraftAnswer>>
@@ -1454,7 +1441,7 @@ function ChatViewContent(props: ChatViewProps) { · 152 unmodified lines
14541441 const isServerThread = activeServerThread !== null;
14551442 const activeThread = activeServerThread ?? localDraftThread;
14561443 const threadError = isServerThread
1457− ? (localServerError ?? activeServerThread?.session?.lastError ?? null)
1444+ ? (localServerError ?? serverThread?.runtime?.lastError ?? null)
14581445 : localDraftError;
14591446 const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
14601447 const interactionMode =
@@ -1576,14 +1563,14 @@ function ChatViewContent(props: ChatViewProps) { · 115 unmodified lines
15761563 const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);
15771564 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));
15781565 }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]);
1579− const activeLatestTurn = activeThread?.latestTurn ?? null;
1566+ const activeLatestRun = activeThread?.latestRun ?? null;
15801567 const sourcePlanThreadRef = useMemo(() => {
1581− const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId;
1568+ const sourceThreadId = activeLatestRun?.sourcePlanRef?.threadId;
15821569 if (!activeThread || !sourceThreadId || sourceThreadId === activeThread.id) {
15831570 return null;
15841571 }
15851572 return scopeThreadRef(activeThread.environmentId, sourceThreadId);
1586− }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread]);
1573+ }, [activeLatestRun?.sourcePlanRef?.threadId, activeThread]);
15871574 const sourceThreadProposedPlans = useThreadProposedPlans(sourcePlanThreadRef);
15881575 const threadPlanCatalog = useMemo<ThreadPlanCatalogEntry[]>(() => {
15891576 if (!activeThread) {
@@ -1615,7 +1602,7 @@ function ChatViewContent(props: ChatViewProps) { · 25 unmodified lines
16151602 : nextThreadIds;
16161603 });
16171604 }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]);
1618− const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
1605+ const latestRunSettled = isLatestRunSettled(activeLatestRun, activeThread?.runtime ?? null);
16191606 const activeProjectRef = activeThread
16201607 ? scopeProjectRef(activeThread.environmentId, activeThread.projectId)
16211608 : null;
@@ -2047,16 +2034,19 @@ function ChatViewContent(props: ChatViewProps) { · 425 unmodified lines
20472034 selectedProviderByThreadId ?? threadProvider,
20482035 );
20492036 const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;
2050− const phase = derivePhase(activeThread?.session ?? null);
2051− const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
2052− const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]);
2037+ const phase = derivePhase(activeThread?.runtime ?? null);
2038+ const threadWorkEntries = activeThread?.workEntries ?? EMPTY_WORK_ENTRIES;
2039+ const workLogEntries = useMemo(
2040+ () => deriveWorkLogEntries(threadWorkEntries),
2041+ [threadWorkEntries],
2042+ );
20532043 const pendingApprovals = useMemo(
2054− () => derivePendingApprovals(threadActivities),
2055− [threadActivities],
2044+ () => derivePendingApprovals(activeThread?.pendingApprovals ?? []),
2045+ [activeThread?.pendingApprovals],
20562046 );
20572047 const pendingUserInputs = useMemo(
2058− () => derivePendingUserInputs(threadActivities),
2059− [threadActivities],
2048+ () => derivePendingUserInputs(activeThread?.pendingUserInputs ?? []),
2049+ [activeThread?.pendingUserInputs],
20602050 );
20612051 const activePendingUserInput = pendingUserInputs[0] ?? null;
20622052 const activePendingDraftAnswers = useMemo(
@@ -2089,36 +2079,37 @@ function ChatViewContent(props: ChatViewProps) { · 26 unmodified lines
20892079 [activePendingDraftAnswers, activePendingUserInput],
20902080 );
20912081 const activePendingIsResponding = activePendingUserInput
2092− ? respondingUserInputRequestIds.includes(activePendingUserInput.requestId)
2082+ ? activePendingUserInput.responseCapability !== "live" ||
2083+ respondingUserInputRequestIds.includes(activePendingUserInput.requestId)
20932084 : false;
20942085 const activeProposedPlan = useMemo(() => {
2095− if (!latestTurnSettled) {
2086+ if (!latestRunSettled) {
20962087 return null;
20972088 }
20982089 return findLatestProposedPlan(
20992090 activeThread?.proposedPlans ?? [],
2100− activeLatestTurn?.turnId ?? null,
2091+ activeLatestRun?.runId ?? null,
21012092 );
2102− }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]);
2093+ }, [activeLatestRun?.runId, activeThread?.proposedPlans, latestRunSettled]);
21032094 const sidebarProposedPlan = useMemo(
21042095 () =>
21052096 findSidebarProposedPlan({
21062097 threads: threadPlanCatalog,
2107− latestTurn: activeLatestTurn,
2108− latestTurnSettled,
2098+ latestRun: activeLatestRun,
2099+ latestRunSettled,
21092100 threadId: activeThread?.id ?? null,
21102101 }),
2111− [activeLatestTurn, activeThread?.id, latestTurnSettled, threadPlanCatalog],
2102+ [activeLatestRun, activeThread?.id, latestRunSettled, threadPlanCatalog],
21122103 );
21132104 const activePlan = useMemo(
2114− () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined),
2115− [activeLatestTurn?.turnId, threadActivities],
2105+ () => deriveActivePlanState(activeThread?.todoPlans ?? [], activeLatestRun?.runId ?? undefined),
2106+ [activeLatestRun?.runId, activeThread?.todoPlans],
21162107 );
21172108 const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks";
21182109 const showPlanFollowUpPrompt =
21192110 pendingUserInputs.length === 0 &&
21202111 interactionMode === "plan" &&
2121− latestTurnSettled &&
2112+ latestRunSettled &&
21222113 hasActionableProposedPlan(activeProposedPlan);
21232114 const activePendingApproval = pendingApprovals[0] ?? null;
21242115 const {
@@ -2129,7 +2120,7 @@ function ChatViewContent(props: ChatViewProps) { · 4 unmodified lines
21292120 isSendBusy,
21302121 } = useLocalDispatchState({
21312122 activeThread,
2132− activeLatestTurn,
2123+ activeLatestRun,
21332124 phase,
21342125 activePendingApproval: activePendingApproval?.requestId ?? null,
21352126 activePendingUserInput: activePendingUserInput?.requestId ?? null,
@@ -2137,8 +2128,8 @@ function ChatViewContent(props: ChatViewProps) { · 1 unmodified line
21372128 });
21382129 const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
21392130 const activeWorkStartedAt = deriveActiveWorkStartedAt(
2140− activeLatestTurn,
2141− activeThread?.session ?? null,
2131+ activeLatestRun,
2132+ activeThread?.runtime ?? null,
21422133 localDispatchStartedAt,
21432134 );
21442135 useEffect(() => {
@@ -2202,7 +2193,17 @@ function ChatViewContent(props: ChatViewProps) { · 57 unmodified lines
22022193 });
22032194 }, []);
22042195 const serverMessages = activeThread?.messages;
2205− const serverAttachmentIds = useMemo(() => {
2196+ const committedServerAttachmentIds = useMemo(() => {
2197+ const attachmentIds = new Set<string>();
2198+ for (const row of serverVisibleTurnItems) {
2199+ if (row.item.type !== "user_message") continue;
2200+ for (const attachment of row.item.attachments) {
2201+ attachmentIds.add(attachment.id);
2202+ }
2203+ }
2204+ return [...attachmentIds];
2205+ }, [serverVisibleTurnItems]);
2206+ const draftAttachmentIds = useMemo(() => {
22062207 const attachmentIds = new Set<string>();
22072208 for (const message of serverMessages ?? []) {
22082209 for (const attachment of message.attachments ?? []) {
@@ -2211,6 +2212,7 @@ function ChatViewContent(props: ChatViewProps) { · 2 unmodified lines
22112212 }
22122213 return [...attachmentIds];
22132214 }, [serverMessages]);
2215+ const serverAttachmentIds = isServerThread ? committedServerAttachmentIds : draftAttachmentIds;
22142216 const serverAttachmentResources = useMemo(
22152217 () =>
22162218 serverAttachmentIds.map((attachmentId) => ({
@@ -2381,22 +2383,22 @@ function ChatViewContent(props: ChatViewProps) { · 164 unmodified lines
23812383 }
23822384 return [...serverMessagesWithPreviewHandoff, ...pendingMessages];
23832385 }, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]);
2384− const timelineEntries = useMemo(
2386+ const serverTimelineEntries = useMemo(
2387+ () =>
2388+ deriveTimelineEntriesFromVisibleTurnItems({
2389+ visibleTurnItems: serverVisibleTurnItems,
2390+ optimisticMessages: optimisticUserMessages,
2391+ attachmentUrlById: serverAttachmentUrlById,
2392+ }),
2393+ [optimisticUserMessages, serverVisibleTurnItems, serverAttachmentUrlById],
2394+ );
2395+ const draftTimelineEntries = useMemo(
23852396 () =>
23862397 deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries),
23872398 [activeThread?.proposedPlans, timelineMessages, workLogEntries],
23882399 );
2389− const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null);
2390− const draftHeroDockRequested =
2391− activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey;
2392− const isDraftHeroState =
2393− isLocalDraftThread && timelineEntries.length === 0 && !isWorking && !draftHeroDockRequested;
2394− const [
2395− attachDraftHeroTransitionGroupRef,
2396− attachDraftHeroComposerAnchorRef,
2397− captureDraftHeroComposerRect,
2398− ] = useDraftHeroLayoutTransition(isDraftHeroState);
2399− const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
2400+ const timelineEntries = isServerThread ? serverTimelineEntries : draftTimelineEntries;
2401+ const { turnDiffSummaries, inferredCheckpointTurnCountByRunId } =
24002402 useTurnDiffSummaries(activeThread);
24012403 const turnDiffSummaryByAssistantMessageId = useMemo(() => {
24022404 const byMessageId = new Map<MessageId, TurnDiffSummary>();
@@ -2427,7 +2429,7 @@ function ChatViewContent(props: ChatViewProps) { · 24 unmodified lines
24272429 continue;
24282430 }
24292431 const turnCount =
2430− summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId];
2432+ summary.checkpointTurnCount ?? inferredCheckpointTurnCountByRunId[summary.runId];
24312433 if (typeof turnCount !== "number") {
24322434 break;
24332435 }
@@ -2437,7 +2439,7 @@ function ChatViewContent(props: ChatViewProps) { · 3 unmodified lines
24372439 }
24382440
24392441 return byUserMessageId;
2440− }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);
2442+ }, [inferredCheckpointTurnCountByRunId, timelineEntries, turnDiffSummaryByAssistantMessageId]);
24412443
24422444 const gitCwd = activeProject
24432445 ? projectScriptCwd({
@@ -2465,7 +2467,7 @@ function ChatViewContent(props: ChatViewProps) { · 21 unmodified lines
24652467 ?.instanceId ?? null;
24662468 const activeProviderInstanceId =
24672469 selectedProviderInstanceId ??
2468− activeThread?.session?.providerInstanceId ??
2470+ activeThread?.runtime?.providerInstanceId ??
24692471 activeThread?.modelSelection.instanceId ??
24702472 activeProject?.defaultModelSelection?.instanceId ??
24712473 null;
@@ -2544,9 +2546,7 @@ function ChatViewContent(props: ChatViewProps) { · 72 unmodified lines
25442546 }, [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen]);
25452547
25462548 const envLocked = Boolean(
2547− activeThread &&
2548− (activeThread.messages.length > 0 ||
2549− (activeThread.session !== null && activeThread.session.status !== "stopped")),
2549+ activeThread && (activeThread.messages.length > 0 || activeThread.runtime !== null),
25502550 );
25512551
25522552 // Handle environment change for draft threads. When the user picks a
@@ -3092,8 +3092,8 @@ function ChatViewContent(props: ChatViewProps) { · 539 unmodified lines
30923092 }, [handleInteractionModeChange, interactionMode]);
30933093 const dismissPlanSidebarForCurrentTurn = useCallback(() => {
30943094 planSidebarDismissedForTurnRef.current =
3095− activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
3096− }, [activePlan?.turnId, sidebarProposedPlan?.turnId]);
3095+ activePlan?.runId ?? sidebarProposedPlan?.runId ?? "__dismissed__";
3096+ }, [activePlan?.runId, sidebarProposedPlan?.runId]);
30973097 const togglePlanSidebar = useCallback(() => {
30983098 if (!activeThreadRef) return;
30993099 if (planSidebarOpen) {
@@ -3842,20 +3842,20 @@ function ChatViewContent(props: ChatViewProps) { · 742 unmodified lines
38423842 if (!autoOpenPlanSidebar) return;
38433843 if (!activePlan) return;
38443844 if (planSidebarOpen) return;
3845− const latestTurnId = activeLatestTurn?.turnId ?? null;
3846− if (latestTurnId && activePlan.turnId !== latestTurnId) return;
3847− const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
3845+ const latestRunId = activeLatestRun?.runId ?? null;
3846+ if (latestRunId && activePlan.runId !== latestRunId) return;
3847+ const turnKey = activePlan.runId ?? sidebarProposedPlan?.runId ?? "__dismissed__";
38483848 if (planSidebarDismissedForTurnRef.current === turnKey) return;
38493849 if (activeThreadRef) {
38503850 useRightPanelStore.getState().open(activeThreadRef, "plan");
38513851 }
38523852 }, [
38533853 activePlan,
3854− activeLatestTurn?.turnId,
3854+ activeLatestRun?.runId,
38553855 activeThreadRef,
38563856 autoOpenPlanSidebar,
38573857 planSidebarOpen,
3858− sidebarProposedPlan?.turnId,
3858+ sidebarProposedPlan?.runId,
38593859 ]);
38603860
38613861 useEffect(() => {
@@ -4753,7 +4753,7 @@ function ChatViewContent(props: ChatViewProps) { · 891 unmodified lines
47534753 role: "user",
47544754 text: outgoingMessageText,
47554755 ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}),
4756− turnId: null,
4756+ runId: null,
47574757 createdAt: messageCreatedAt,
47584758 updatedAt: messageCreatedAt,
47594759 streaming: false,
@@ -4966,8 +4966,13 @@ function ChatViewContent(props: ChatViewProps) { · 206 unmodified lines
49664966 };
49674967
49684968 const onRespondToApproval = useCallback(
4969− async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => {
4969+ async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => {
49704970 if (!activeThreadId) return;
4971+ if (
4972+ pendingApprovals.find((approval) => approval.requestId === requestId)
4973+ ?.responseCapability !== "live"
4974+ )
4975+ return;
49714976
49724977 setRespondingRequestIds((existing) =>
49734978 existing.includes(requestId) ? existing : [...existing, requestId],
@@ -4990,12 +4995,17 @@ function ChatViewContent(props: ChatViewProps) { · 16 unmodified lines
49904995 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
49914996 return result;
49924997 },
4993− [activeThreadId, environmentId, respondToThreadApproval, setThreadError],
4998+ [activeThreadId, environmentId, pendingApprovals, respondToThreadApproval, setThreadError],
49944999 );
49955000
49965001 const onRespondToUserInput = useCallback(
4997− async (requestId: ApprovalRequestId, answers: Record<string, unknown>) => {
5002+ async (requestId: RuntimeRequestId, answers: Record<string, unknown>) => {
49985003 if (!activeThreadId) return;
5004+ if (
5005+ pendingUserInputs.find((input) => input.requestId === requestId)?.responseCapability !==
5006+ "live"
5007+ )
5008+ return;
49995009
50005010 setRespondingUserInputRequestIds((existing) =>
50015011 existing.includes(requestId) ? existing : [...existing, requestId],
@@ -5018,7 +5028,7 @@ function ChatViewContent(props: ChatViewProps) { · 16 unmodified lines
50185028 setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId));
50195029 return result;
50205030 },
5021− [activeThreadId, environmentId, respondToThreadUserInput, setThreadError],
5031+ [activeThreadId, environmentId, pendingUserInputs, respondToThreadUserInput, setThreadError],
50225032 );
50235033
50245034 const setActivePendingUserInputQuestionIndex = useCallback(
@@ -5102,7 +5112,11 @@ function ChatViewContent(props: ChatViewProps) { · 77 unmodified lines
51025112 );
51035113
51045114 const onAdvanceActivePendingUserInput = useCallback(() => {
5105− if (!activePendingUserInput || !activePendingProgress) {
5115+ if (
5116+ !activePendingUserInput ||
5117+ activePendingUserInput.responseCapability !== "live" ||
5118+ !activePendingProgress
5119+ ) {
51065120 return;
51075121 }
51085122 if (activePendingProgress.isLastQuestion) {
@@ -5196,7 +5210,7 @@ function ChatViewContent(props: ChatViewProps) { · 87 unmodified lines
51965210 id: messageIdForSend,
51975211 role: "user",
51985212 text: outgoingMessageText,
5199− turnId: null,
5213+ runId: null,
52005214 createdAt: messageCreatedAt,
52015215 updatedAt: messageCreatedAt,
52025216 streaming: false,
@@ -5466,9 +5480,9 @@ function ChatViewContent(props: ChatViewProps) { · 263 unmodified lines
54665480 }
54675481 const reason = getStartedThreadModelChangeBlockReason({
54685482 providers: providerStatuses,
5469− hasStartedSession: activeThread.session !== null,
5483+ hasStartedSession: activeThread.runtime !== null,
54705484 currentModelSelection: activeThread.modelSelection,
5471− currentProviderInstanceId: activeThread.session?.providerInstanceId ?? null,
5485+ currentProviderInstanceId: activeThread.runtime?.providerInstanceId ?? null,
54725486 nextModelSelection: { instanceId, model },
54735487 });
54745488 return reason ? `${reason.description} Start a new thread to use this model.` : null;
@@ -5492,9 +5506,9 @@ function ChatViewContent(props: ChatViewProps) { · 17 unmodified lines
54925506 scheduleComposerFocus();
54935507 return;
54945508 }
5495− if (lockedProvider !== null && activeThread.session?.providerInstanceId) {
5509+ if (lockedProvider !== null && activeThread.runtime?.providerInstanceId) {
54965510 const currentEntry = providerStatuses.find(
5497− (snapshot) => snapshot.instanceId === activeThread.session?.providerInstanceId,
5511+ (snapshot) => snapshot.instanceId === activeThread.runtime?.providerInstanceId,
54985512 );
54995513 if (
55005514 currentEntry?.continuation?.groupKey &&
@@ -5521,9 +5535,9 @@ function ChatViewContent(props: ChatViewProps) { · 20 unmodified lines
55215535 };
55225536 const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({
55235537 providers: providerStatuses,
5524− hasStartedSession: activeThread.session !== null,
5538+ hasStartedSession: activeThread.runtime !== null,
55255539 currentModelSelection: activeThread.modelSelection,
5526− currentProviderInstanceId: activeThread.session?.providerInstanceId ?? null,
5540+ currentProviderInstanceId: activeThread.runtime?.providerInstanceId ?? null,
55275541 nextModelSelection,
55285542 });
55295543 if (modelChangeBlockReason) {
@@ -5603,9 +5617,9 @@ function ChatViewContent(props: ChatViewProps) { · 73 unmodified lines
56035617 setExpandedImage(preview);
56045618 }, []);
56055619 const onOpenTurnDiff = useCallback(
5606− (turnId: TurnId, filePath?: string) => {
5620+ (runId: RunId, filePath?: string) => {
56075621 if (!isServerThread || !activeThreadRef) return;
5608− useDiffPanelStore.getState().selectTurn(activeThreadRef, turnId, filePath);
5622+ useDiffPanelStore.getState().selectTurn(activeThreadRef, runId, filePath);
56095623 useRightPanelStore.getState().open(activeThreadRef, "diff");
56105624 onDiffPanelOpen?.();
56115625 },
@@ -5800,16 +5814,11 @@ function ChatViewContent(props: ChatViewProps) { · 188 unmodified lines
58005814 <MessagesTimeline
58015815 key={activeThread.id}
58025816 isWorking={isWorking}
5803− activeTurnInProgress={isWorking || !latestTurnSettled}
5817+ activeTurnInProgress={isWorking || !latestRunSettled}
58045818 activeTurnStartedAt={activeWorkStartedAt}
58055819 listRef={legendListRef}
58065820 timelineEntries={timelineEntries}
5807− latestTurn={activeLatestTurn}
5808− runningTurnId={
5809− activeThread.session?.status === "running"
5810− ? activeThread.session.activeTurnId
5811− : null
5812− }
5821+ latestRun={activeLatestRun}
58135822 turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}
58145823 activeThreadEnvironmentId={activeThread.environmentId}
58155824 routeThreadKey={routeThreadKey}
@@ -5867,168 +5876,86 @@ function ChatViewContent(props: ChatViewProps) { · 51 unmodified lines
58675876 ref={attachDraftHeroTransitionGroupRef}
58685877 className="chat-composer-horizontal-inset w-full"
58695878 >
5870− <div className="pointer-events-auto relative z-10">
5871− {isDraftHeroState ? (
5872− <div className="absolute inset-x-0 bottom-full z-0">
5873− <div
5874− className="pb-8"
5875− style={
5876− forceExpandedMobileComposer
5877− ? {
5878− viewTransitionName: MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME,
5879− }
5880− : undefined
5881− }
5882− >
5883− <DraftHeroHeadline
5884− activeProjectRef={activeProjectRef}
5885− activeProjectTitle={activeProject?.title ?? null}
5886− />
5887− </div>
5888− <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
5889− </div>
5890− ) : (
5891− <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
5892− )}
5893− {threadSyncPhase && !activeEnvironmentUnavailable ? (
5894− <ThreadSyncStatusPill phase={threadSyncPhase} />
5895− ) : null}
5896− <div
5897− className="relative"
5898− style={
5899− forceExpandedMobileComposer
5900− ? { viewTransitionName: MOBILE_COMPOSER_VIEW_TRANSITION_NAME }
5901− : undefined
5902− }
5903− >
5904− <div
5905− className={cn(
5906− "chat-composer-glass-shell relative mx-auto w-full max-w-3xl",
5907− showComposerContextStrip && "chat-composer-glass-shell-with-context",
5908− )}
5909− >
5910− <div className="chat-composer-glass-host relative z-10 w-full rounded-[22px]">
5911− <div ref={attachDraftHeroComposerAnchorRef} className="relative z-10">
5912− <ChatComposer
5913− composerRef={composerRef}
5914− composerDraftTarget={composerDraftTarget}
5915− environmentId={environmentId}
5916− routeKind={routeKind}
5917− routeThreadRef={routeThreadRef}
5918− draftId={draftId}
5919− activeThreadId={activeThreadId}
5920− activeThreadEnvironmentId={activeThread?.environmentId}
5921− activeThread={activeThread}
5922− isServerThread={isServerThread}
5923− isLocalDraftThread={isLocalDraftThread}
5924− forceExpandedOnMobile={forceExpandedMobileComposer && isDraftHeroState}
5925− projectSelectionRequired={isLocalDraftThread && activeProject === null}
5926− phase={phase}
5927− isConnecting={isConnecting}
5928− isSendBusy={isSendBusy}
5929− sendDisabledReason={threadDetailLoading ? "Messages loading" : null}
5930− isPreparingWorktree={isPreparingWorktree}
5931− environmentUnavailable={activeEnvironmentUnavailableState}
5932− activePendingApproval={activePendingApproval}
5933− pendingApprovals={pendingApprovals}
5934− pendingUserInputs={pendingUserInputs}
5935− activePendingProgress={activePendingProgress}
5936− activePendingResolvedAnswers={activePendingResolvedAnswers}
5937− activePendingIsResponding={activePendingIsResponding}
5938− activePendingDraftAnswers={activePendingDraftAnswers}
5939− activePendingQuestionIndex={activePendingQuestionIndex}
5940− respondingRequestIds={respondingRequestIds}
5941− showPlanFollowUpPrompt={showPlanFollowUpPrompt}
5942− activeProposedPlan={activeProposedPlan}
5943− activePlan={activePlan as { turnId?: TurnId } | null}
5944− sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null}
5945− planSidebarLabel={planSidebarLabel}
5946− planSidebarOpen={planSidebarOpen}
5947− runtimeMode={runtimeMode}
5948− interactionMode={interactionMode}
5949− lockedProvider={lockedProvider}
5950− providerStatuses={providerStatuses as ServerProvider[]}
5951− activeProjectDefaultModelSelection={
5952− activeProject?.defaultModelSelection
5953− }
5954− activeThreadModelSelection={activeThread?.modelSelection}
5955− activeThreadActivities={activeThread?.activities}
5956− resolvedTheme={resolvedTheme}
5957− settings={settings}
5958− keybindings={keybindings}
5959− terminalOpen={Boolean(terminalUiState.terminalOpen)}
5960− gitCwd={gitCwd}
5961− promptRef={promptRef}
5962− composerImagesRef={composerImagesRef}
5963− composerTerminalContextsRef={composerTerminalContextsRef}
5964− composerElementContextsRef={composerElementContextsRef}
5965− onSend={onSend}
5966− onInterrupt={onInterrupt}
5967− onImplementPlanInNewThread={onImplementPlanInNewThread}
5968− onRespondToApproval={onRespondToApproval}
5969− onSelectActivePendingUserInputOption={
5970− onSelectActivePendingUserInputOption
5971− }
5972− onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput}
5973− onPreviousActivePendingUserInputQuestion={
5974− onPreviousActivePendingUserInputQuestion
5975− }
5976− onChangeActivePendingUserInputCustomAnswer={
5977− onChangeActivePendingUserInputCustomAnswer
5978− }
5979− onProviderModelSelect={onProviderModelSelect}
5980− getModelDisabledReason={getModelDisabledReason}
5981− toggleInteractionMode={toggleInteractionMode}
5982− handleRuntimeModeChange={handleRuntimeModeChange}
5983− handleInteractionModeChange={handleInteractionModeChange}
5984− togglePlanSidebar={togglePlanSidebar}
5985− focusComposer={focusComposer}
5986− scheduleComposerFocus={scheduleComposerFocus}
5987− setThreadError={setThreadError}
5988− onExpandImage={onExpandTimelineImage}
5989− />
5990− </div>
5991− </div>
5992− <div className="min-h-0">
5993− <div
5994− data-terminal-open={terminalUiState.terminalOpen ? "true" : undefined}
5995− className="relative z-0"
5996− >
5997− {showComposerContextStrip && (
5998− <div className="pointer-events-auto">
5999− <BranchToolbar
6000− environmentId={activeThread.environmentId}
6001− threadId={activeThread.id}
6002− {...(routeKind === "draft" && draftId ? { draftId } : {})}
6003− onEnvModeChange={onEnvModeChange}
6004− startFromOrigin={startFromOrigin}
6005− onStartFromOriginChange={onStartFromOriginChange}
6006− {...(canOverrideServerThreadEnvMode
6007− ? { effectiveEnvModeOverride: envMode }
6008− : {})}
6009− {...(canOverrideServerThreadEnvMode
6010− ? {
6011− activeThreadBranchOverride: activeThreadBranch,
6012− onActiveThreadBranchOverrideChange:
6013− setPendingServerThreadBranch,
6014− }
6015− : {})}
6016− envLocked={envLocked}
6017− onComposerFocusRequest={scheduleComposerFocus}
6018− {...(canCheckoutPullRequestIntoThread
6019− ? { onCheckoutPullRequestRequest: openPullRequestDialog }
6020− : {})}
6021− {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})}
6022− availableEnvironments={logicalProjectEnvironments}
6023− />
6024− </div>
6025− )}
6026− </div>
6027− </div>
6028− </div>
6029− <div
6030− aria-hidden
6031− className="h-[calc(env(safe-area-inset-bottom)+1rem)] sm:h-[calc(env(safe-area-inset-bottom)+1.25rem)]"
5879+ <div className="relative mx-auto h-full w-full max-w-208 overflow-clip rounded-t-[20px]">
5880+ <div className="chat-composer-shared-blur absolute -inset-8" />
5881+ </div>
5882+ </div>
5883+ <div className="chat-composer-horizontal-inset">
5884+ <div className="pointer-events-auto relative z-10 isolate">
5885+ <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
5886+ <div className="relative z-10">
5887+ <ChatComposer
5888+ composerRef={composerRef}
5889+ composerDraftTarget={composerDraftTarget}
5890+ environmentId={environmentId}
5891+ routeKind={routeKind}
5892+ routeThreadRef={routeThreadRef}
5893+ draftId={draftId}
5894+ activeThreadId={activeThreadId}
5895+ activeThreadEnvironmentId={activeThread?.environmentId}
5896+ activeThread={activeThread}
5897+ isServerThread={isServerThread}
5898+ isLocalDraftThread={isLocalDraftThread}
5899+ phase={phase}
5900+ isConnecting={isConnecting}
5901+ isSendBusy={isSendBusy}
5902+ isPreparingWorktree={isPreparingWorktree}
5903+ environmentUnavailable={activeEnvironmentUnavailableState}
5904+ activePendingApproval={activePendingApproval}
5905+ pendingApprovals={pendingApprovals}
5906+ pendingUserInputs={pendingUserInputs}
5907+ activePendingProgress={activePendingProgress}
5908+ activePendingResolvedAnswers={activePendingResolvedAnswers}
5909+ activePendingIsResponding={activePendingIsResponding}
5910+ activePendingDraftAnswers={activePendingDraftAnswers}
5911+ activePendingQuestionIndex={activePendingQuestionIndex}
5912+ respondingRequestIds={respondingRequestIds}
5913+ showPlanFollowUpPrompt={showPlanFollowUpPrompt}
5914+ activeProposedPlan={activeProposedPlan}
5915+ activePlan={activePlan}
5916+ sidebarProposedPlan={sidebarProposedPlan}
5917+ planSidebarLabel={planSidebarLabel}
5918+ planSidebarOpen={planSidebarOpen}
5919+ runtimeMode={runtimeMode}
5920+ interactionMode={interactionMode}
5921+ lockedProvider={lockedProvider}
5922+ providerStatuses={providerStatuses as ServerProvider[]}
5923+ activeProjectDefaultModelSelection={activeProject?.defaultModelSelection}
5924+ activeThreadModelSelection={activeThread?.modelSelection}
5925+ activeThreadWorkEntries={activeThread?.workEntries}
5926+ resolvedTheme={resolvedTheme}
5927+ settings={settings}
5928+ keybindings={keybindings}
5929+ terminalOpen={Boolean(terminalUiState.terminalOpen)}
5930+ gitCwd={gitCwd}
5931+ promptRef={promptRef}
5932+ composerImagesRef={composerImagesRef}
5933+ composerTerminalContextsRef={composerTerminalContextsRef}
5934+ composerElementContextsRef={composerElementContextsRef}
5935+ shouldAutoScrollRef={isAtEndRef}
5936+ scheduleStickToBottom={scrollToEnd}
5937+ onSend={onSend}
5938+ onInterrupt={onInterrupt}
5939+ onImplementPlanInNewThread={onImplementPlanInNewThread}
5940+ onRespondToApproval={onRespondToApproval}
5941+ onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption}
5942+ onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput}
5943+ onPreviousActivePendingUserInputQuestion={
5944+ onPreviousActivePendingUserInputQuestion
5945+ }
5946+ onChangeActivePendingUserInputCustomAnswer={
5947+ onChangeActivePendingUserInputCustomAnswer
5948+ }
5949+ onProviderModelSelect={onProviderModelSelect}
5950+ getModelDisabledReason={getModelDisabledReason}
5951+ toggleInteractionMode={toggleInteractionMode}
5952+ handleRuntimeModeChange={handleRuntimeModeChange}
5953+ handleInteractionModeChange={handleInteractionModeChange}
5954+ togglePlanSidebar={togglePlanSidebar}
5955+ focusComposer={focusComposer}
5956+ scheduleComposerFocus={scheduleComposerFocus}
5957+ setThreadError={setThreadError}
5958+ onExpandImage={onExpandTimelineImage}
60325959 />
60335960 </div>
60345961 </div>
apps/web/src/components/CommandPalette.logic.test.ts+5−6
@@ -1,6 +1,7 @@
11 import { describe, expect, it, vi } from "vite-plus/test";
22 import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
33 import type { Thread } from "../types";
4+import { makeThreadFixture } from "../test-fixtures";
45 import {
56 buildBrowseGroups,
67 buildThreadActionItems,
@@ -112,7 +113,7 @@ const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); · 105 unmodified lines
112113 const PROJECT_ID = ProjectId.make("project-1");
113114
114115 function makeThread(overrides: Partial<Thread> = {}): Thread {
115− return {
116+ return makeThreadFixture({
116117 id: ThreadId.make("thread-1"),
117118 environmentId: LOCAL_ENVIRONMENT_ID,
118119 projectId: PROJECT_ID,
@@ -120,7 +121,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 1 unmodified line
120121 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
121122 runtimeMode: "full-access",
122123 interactionMode: "default",
123− session: null,
124+ runtime: null,
124125 messages: [],
125126 proposedPlans: [],
126127 createdAt: "2026-03-01T00:00:00.000Z",
@@ -129,13 +130,11 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 2 unmodified lines
129130 settledAt: null,
130131 deletedAt: null,
131132 updatedAt: "2026-03-01T00:00:00.000Z",
132− latestTurn: null,
133+ latestRun: null,
133134 branch: null,
134135 worktreePath: null,
135− checkpoints: [],
136− activities: [],
137136 ...overrides,
138− };
137+ });
139138 }
140139
141140 describe("buildThreadActionItems", () => {
apps/web/src/components/DiffPanel.tsx+40−45
@@ -5,7 +5,7 @@ import { · 4 unmodified lines
55 squashAtomCommandFailure,
66 } from "@t3tools/client-runtime/state/runtime";
77 import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
8−import type { ScopedThreadRef, TurnId } from "@t3tools/contracts";
8+import type { ScopedThreadRef, RunId } from "@t3tools/contracts";
99 import {
1010 ArrowRightIcon,
1111 CheckIcon,
@@ -246,55 +246,55 @@ export default function DiffPanel({ · 234 unmodified lines
246246 ),
247247 );
248248 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
249− const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
249+ const { turnDiffSummaries, inferredCheckpointTurnCountByRunId } =
250250 useTurnDiffSummaries(activeThread);
251251 const orderedTurnDiffSummaries = useMemo(
252252 () =>
253253 [...turnDiffSummaries].toSorted((left, right) => {
254254 const leftTurnCount =
255− left.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[left.turnId] ?? 0;
255+ left.checkpointTurnCount ?? inferredCheckpointTurnCountByRunId[left.runId] ?? 0;
256256 const rightTurnCount =
257− right.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[right.turnId] ?? 0;
257+ right.checkpointTurnCount ?? inferredCheckpointTurnCountByRunId[right.runId] ?? 0;
258258 if (leftTurnCount !== rightTurnCount) {
259259 return rightTurnCount - leftTurnCount;
260260 }
261261 return right.completedAt.localeCompare(left.completedAt);
262262 }),
263− [inferredCheckpointTurnCountByTurnId, turnDiffSummaries],
263+ [inferredCheckpointTurnCountByRunId, turnDiffSummaries],
264264 );
265265
266266 useEffect(() => {
267267 if (!routeThreadRef || diffSelection.kind !== "turn") return;
268268 useDiffPanelStore.getState().reconcileTurnSelection(
269269 routeThreadRef,
270− orderedTurnDiffSummaries.map((summary) => summary.turnId),
270+ orderedTurnDiffSummaries.map((summary) => summary.runId),
271271 );
272272 }, [diffSelection, orderedTurnDiffSummaries, routeThreadRef]);
273273
274− const selectedTurnId = diffSelection.kind === "turn" ? diffSelection.turnId : null;
274+ const selectedRunId = diffSelection.kind === "turn" ? diffSelection.turnId : null;
275275 const selectedGitScope = diffSelection.kind === "unstaged" ? "unstaged" : "branch";
276276 const selectedBaseRef = diffSelection.kind === "branch" ? diffSelection.baseRef : null;
277277 const selectedFilePath = diffSelection.kind === "turn" ? diffSelection.filePath : null;
278278 const selectedFileRevealRequestId =
279279 diffSelection.kind === "turn" ? diffSelection.revealRequestId : 0;
280280 const selectedTurn =
281− selectedTurnId === null
281+ selectedRunId === null
282282 ? undefined
283− : (orderedTurnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ??
283+ : (orderedTurnDiffSummaries.find((summary) => summary.runId === selectedRunId) ??
284284 orderedTurnDiffSummaries[0]);
285285 const selectedCheckpointTurnCount =
286286 selectedTurn &&
287− (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]);
287+ (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByRunId[selectedTurn.runId]);
288288 const latestTurn = orderedTurnDiffSummaries[0];
289289 const selectedScopeLabel =
290− selectedTurnId === null
290+ selectedRunId === null
291291 ? selectedGitScope === "unstaged"
292292 ? "Working tree"
293293 : "Branch changes"
294− : selectedTurn?.turnId === latestTurn?.turnId
294+ : selectedTurn?.runId === latestTurn?.runId
295295 ? "Latest turn"
296296 : `Turn ${selectedCheckpointTurnCount ?? "?"}`;
297− const reviewSectionId = selectedTurn ? `turn:${selectedTurn.turnId}` : selectedGitScope;
297+ const reviewSectionId = selectedTurn ? `turn:${selectedTurn.runId}` : selectedGitScope;
298298 const collapseScopeKey = routeThreadRef
299299 ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}`
300300 : null;
@@ -324,12 +324,12 @@ export default function DiffPanel({ · 23 unmodified lines
324324 fromTurnCount: selectedCheckpointRange?.fromTurnCount ?? null,
325325 toTurnCount: selectedCheckpointRange?.toTurnCount ?? null,
326326 ignoreWhitespace: diffIgnoreWhitespace,
327− cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : null,
327+ cacheScope: selectedTurn ? `turn:${selectedTurn.runId}` : null,
328328 },
329329 { enabled: isGitRepo && selectedTurn !== undefined },
330330 );
331331 const primaryBranchDiffPreview = useEnvironmentQuery(
332− selectedTurnId === null && activeThread && activeCwd
332+ selectedRunId === null && activeThread && activeCwd
333333 ? reviewEnvironment.diffPreview({
334334 environmentId: activeThread.environmentId,
335335 input: {
@@ -341,7 +341,7 @@ export default function DiffPanel({ · 5 unmodified lines
341341 : null,
342342 );
343343 const shouldRetryBranchDiffAtEnvironmentCwd =
344− selectedTurnId === null &&
344+ selectedRunId === null &&
345345 primaryBranchDiffPreview.error?.includes("configured workspace root") === true &&
346346 serverConfig?.cwd !== undefined &&
347347 serverConfig.cwd !== activeCwd;
@@ -364,7 +364,7 @@ export default function DiffPanel({ · 16 unmodified lines
364364 (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"),
365365 );
366366 const localBranchRefs = useEnvironmentQuery(
367− selectedTurnId === null &&
367+ selectedRunId === null &&
368368 selectedGitScope === "branch" &&
369369 activeThread &&
370370 branchDiffPreview.data?.cwd
@@ -381,7 +381,7 @@ export default function DiffPanel({ · 10 unmodified lines
381381 : null,
382382 );
383383 const remoteBranchRefs = useEnvironmentQuery(
384− selectedTurnId === null &&
384+ selectedRunId === null &&
385385 selectedGitScope === "branch" &&
386386 activeThread &&
387387 branchDiffPreview.data?.cwd
@@ -424,9 +424,9 @@ export default function DiffPanel({ · 36 unmodified lines
424424 const renderablePatch = useMemo(
425425 () =>
426426 getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`, {
427− compactPartialHunkOffsets: selectedTurnId === null,
427+ compactPartialHunkOffsets: selectedRunId === null,
428428 }),
429− [resolvedTheme, selectedPatch, selectedTurnId],
429+ [resolvedTheme, selectedPatch, selectedRunId],
430430 );
431431 const renderableFiles = useMemo(() => {
432432 if (!renderablePatch || renderablePatch.kind !== "files") {
@@ -505,21 +505,9 @@ export default function DiffPanel({ · 72 unmodified lines
505505 [collapseScopeKey],
506506 );
507507
508− const toggleDiffFileCollapse = useCallback(() => {
509− setCollapsedDiffFiles((current) => {
510− const currentKeys =
511− current.scopeKey === collapseScopeKey ? current.fileKeys : EMPTY_COLLAPSED_DIFF_FILE_KEYS;
512−
513− return {
514− scopeKey: collapseScopeKey,
515− fileKeys: toggleAllDiffFiles(diffFileKeys, currentKeys),
516− };
517− });
518− }, [collapseScopeKey, diffFileKeys]);
519−
520− const selectTurn = (turnId: TurnId) => {
508+ const selectTurn = (runId: RunId) => {
521509 if (!routeThreadRef) return;
522− useDiffPanelStore.getState().selectTurn(routeThreadRef, turnId);
510+ useDiffPanelStore.getState().selectTurn(routeThreadRef, runId);
523511 };
524512 const selectGitScope = (scope: "branch" | "unstaged") => {
525513 if (!routeThreadRef) return;
@@ -551,6 +539,15 @@ export default function DiffPanel({ · 25 unmodified lines
551539 onClick={() => selectGitScope("unstaged")}
552540 >
553541 <span>Working tree</span>
542+ {selectedRunId === null && selectedGitScope === "unstaged" && (
543+ <CheckIcon className="ml-auto" />
544+ )}
545+ </DropdownMenuItem>
546+ <DropdownMenuItem onClick={() => selectGitScope("branch")}>
547+ <span>Branch changes</span>
548+ {selectedRunId === null && selectedGitScope === "branch" && (
549+ <CheckIcon className="ml-auto" />
550+ )}
554551 </DropdownMenuItem>
555552 <DropdownMenuItem
556553 className={
@@ -569,10 +566,13 @@ export default function DiffPanel({ · 12 unmodified lines
569566 : undefined
570567 }
571568 onClick={() => {
572− if (latestTurn) selectTurn(latestTurn.turnId);
569+ if (latestTurn) selectTurn(latestTurn.runId);
573570 }}
574571 >
575572 <span>Latest turn</span>
573+ {selectedRunId !== null && selectedTurn?.runId === latestTurn?.runId && (
574+ <CheckIcon className="ml-auto" />
575+ )}
576576 </DropdownMenuItem>
577577 <DropdownMenuSub>
578578 <DropdownMenuSubTrigger>Turn</DropdownMenuSubTrigger>
@@ -580,20 +580,15 @@ export default function DiffPanel({ · 1 unmodified line
580580 {orderedTurnDiffSummaries.map((summary) => {
581581 const turnCount =
582582 summary.checkpointTurnCount ??
583− inferredCheckpointTurnCountByTurnId[summary.turnId] ??
583+ inferredCheckpointTurnCountByRunId[summary.runId] ??
584584 "?";
585585 return (
586− <DropdownMenuItem
587− key={summary.turnId}
588− className={
589− summary.turnId === selectedTurn?.turnId ? "bg-foreground/[0.08]" : undefined
590− }
591− onClick={() => selectTurn(summary.turnId)}
592− >
586+ <DropdownMenuItem key={summary.runId} onClick={() => selectTurn(summary.runId)}>
593587 <span>Turn {turnCount}</span>
594588 <span className="ml-auto text-xs tabular-nums text-muted-foreground">
595589 {formatShortTimestamp(summary.completedAt, settings.timestampFormat)}
596590 </span>
591+ {summary.runId === selectedTurn?.runId && <CheckIcon className="ml-1" />}
597592 </DropdownMenuItem>
598593 );
599594 })}
@@ -601,7 +596,7 @@ export default function DiffPanel({ · 1 unmodified line
601596 </DropdownMenuSub>
602597 </DropdownMenuContent>
603598 </DropdownMenu>
604− {selectedTurnId === null && selectedGitScope === "branch" && selectedGitSource?.baseRef && (
599+ {selectedRunId === null && selectedGitScope === "branch" && selectedGitSource?.baseRef && (
605600 <div
606601 className="flex min-w-0 max-w-full items-center gap-2 overflow-hidden text-xs text-muted-foreground"
607602 title={`${selectedGitSource.headRef ?? "HEAD"} → ${selectedGitSource.baseRef}`}
@@ -823,7 +818,7 @@ export default function DiffPanel({ · 215 unmodified lines
823818 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
824819 Turn diffs are unavailable because this project is not a git repository.
825820 </div>
826− ) : selectedTurnId !== null && orderedTurnDiffSummaries.length === 0 ? (
821+ ) : selectedRunId !== null && orderedTurnDiffSummaries.length === 0 ? (
827822 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
828823 No completed turns yet.
829824 </div>
apps/web/src/components/Sidebar.logic.test.ts+34−44
@@ -31,20 +31,14 @@ import { · 30 unmodified lines
3131 sortScopedProjectsForSidebar,
3232 THREAD_JUMP_HINT_SHOW_DELAY_MS,
3333 } from "./Sidebar.logic";
34−import {
35− EnvironmentId,
36− OrchestrationLatestTurn,
37− ProjectId,
38− ProviderInstanceId,
39− ThreadId,
40−} from "@t3tools/contracts";
41−
34+import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
4235 import {
4336 DEFAULT_INTERACTION_MODE,
4437 DEFAULT_RUNTIME_MODE,
4538 type Project,
4639 type Thread,
4740 } from "../types";
41+import { makeThreadFixture } from "../test-fixtures";
4842
4943 const localEnvironmentId = EnvironmentId.make("environment-local");
5044
@@ -239,13 +233,13 @@ describe("resolveSidebarStageBadgeLabel", () => { · 188 unmodified lines
239233 });
240234 });
241235
242−function makeLatestTurn(overrides?: {
236+function makeLatestRun(overrides?: {
243237 completedAt?: string | null;
244238 startedAt?: string | null;
245−}): OrchestrationLatestTurn {
239+}): NonNullable<Thread["latestRun"]> {
246240 return {
247− turnId: "turn-1" as never,
248− state: "completed",
241+ runId: "turn-1" as never,
242+ status: "completed",
249243 assistantMessageId: null,
250244 requestedAt: "2026-03-09T10:00:00.000Z",
251245 startedAt:
@@ -263,9 +257,9 @@ describe("hasUnseenCompletion", () => { · 11 unmodified lines
263257 hasPendingApprovals: false,
264258 hasPendingUserInput: false,
265259 interactionMode: "default",
266− latestTurn: makeLatestTurn(),
260+ latestRun: makeLatestRun(),
267261 lastVisitedAt: "2026-03-09T10:04:00.000Z",
268− session: null,
262+ runtime: null,
269263 }),
270264 ).toBe(true);
271265 });
@@ -277,9 +271,9 @@ describe("hasUnseenCompletion", () => { · 5 unmodified lines
277271 hasPendingApprovals: false,
278272 hasPendingUserInput: false,
279273 interactionMode: "default",
280− latestTurn: makeLatestTurn(),
274+ latestRun: makeLatestRun(),
281275 lastVisitedAt: undefined,
282− session: null,
276+ runtime: null,
283277 }),
284278 ).toBe(false);
285279 });
@@ -879,15 +873,13 @@ describe("resolveThreadStatusPill", () => { · 593 unmodified lines
879873 hasPendingApprovals: false,
880874 hasPendingUserInput: false,
881875 interactionMode: "plan" as const,
882− latestTurn: null,
876+ latestRun: null,
883877 lastVisitedAt: undefined,
884− session: {
885− threadId: ThreadId.make("thread-1"),
878+ runtime: {
886879 status: "running" as const,
887880 providerName: "Codex",
888881 providerInstanceId: ProviderInstanceId.make("codex"),
889− runtimeMode: DEFAULT_RUNTIME_MODE,
890− activeTurnId: "turn-1" as never,
882+ activeRunId: "turn-1" as never,
891883 lastError: null,
892884 updatedAt: "2026-03-09T10:00:00.000Z",
893885 },
@@ -930,11 +922,11 @@ describe("resolveThreadStatusPill", () => { · 36 unmodified lines
930922 thread: {
931923 ...baseThread,
932924 hasActionableProposedPlan: true,
933− latestTurn: makeLatestTurn(),
934− session: {
935− ...baseThread.session,
936− status: "ready",
937− activeTurnId: null,
925+ latestRun: makeLatestRun(),
926+ runtime: {
927+ ...baseThread.runtime,
928+ status: "completed",
929+ activeRunId: null,
938930 },
939931 },
940932 }),
@@ -946,11 +938,11 @@ describe("resolveThreadStatusPill", () => { · 5 unmodified lines
946938 resolveThreadStatusPill({
947939 thread: {
948940 ...baseThread,
949− latestTurn: makeLatestTurn(),
950− session: {
951− ...baseThread.session,
952− status: "ready",
953− activeTurnId: null,
941+ latestRun: makeLatestRun(),
942+ runtime: {
943+ ...baseThread.runtime,
944+ status: "completed",
945+ activeRunId: null,
954946 },
955947 },
956948 }),
@@ -963,12 +955,12 @@ describe("resolveThreadStatusPill", () => { · 6 unmodified lines
963955 thread: {
964956 ...baseThread,
965957 interactionMode: "default",
966− latestTurn: makeLatestTurn(),
958+ latestRun: makeLatestRun(),
967959 lastVisitedAt: "2026-03-09T10:04:00.000Z",
968− session: {
969− ...baseThread.session,
970− status: "ready",
971− activeTurnId: null,
960+ runtime: {
961+ ...baseThread.runtime,
962+ status: "completed",
963+ activeRunId: null,
972964 },
973965 },
974966 }),
@@ -1120,7 +1112,7 @@ function makeProject(overrides: Partial<Project> = {}): Project { · 145 unmodified lines
11201112 }
11211113
11221114 function makeThread(overrides: Partial<Thread> = {}): Thread {
1123− return {
1115+ return makeThreadFixture({
11241116 id: ThreadId.make("thread-1"),
11251117 environmentId: localEnvironmentId,
11261118 projectId: ProjectId.make("project-1"),
@@ -1132,7 +1124,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 5 unmodified lines
11321124 },
11331125 runtimeMode: DEFAULT_RUNTIME_MODE,
11341126 interactionMode: DEFAULT_INTERACTION_MODE,
1135− session: null,
1127+ runtime: null,
11361128 messages: [],
11371129 proposedPlans: [],
11381130 createdAt: "2026-03-09T10:00:00.000Z",
@@ -1141,13 +1133,11 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 2 unmodified lines
11411133 settledAt: null,
11421134 deletedAt: null,
11431135 updatedAt: "2026-03-09T10:00:00.000Z",
1144− latestTurn: null,
1136+ latestRun: null,
11451137 branch: null,
11461138 worktreePath: null,
1147− checkpoints: [],
1148− activities: [],
11491139 ...overrides,
1150− };
1140+ });
11511141 }
11521142
11531143 describe("getFallbackThreadIdAfterDelete", () => {
@@ -1231,7 +1221,7 @@ describe("sortProjectsForSidebar", () => { · 77 unmodified lines
12311221 id: "message-1" as never,
12321222 role: "user",
12331223 text: "older project user message",
1234− turnId: null,
1224+ runId: null,
12351225 createdAt: "2026-03-09T10:01:00.000Z",
12361226 updatedAt: "2026-03-09T10:01:00.000Z",
12371227 streaming: false,
@@ -1247,7 +1237,7 @@ describe("sortProjectsForSidebar", () => { · 9 unmodified lines
12471237 id: "message-2" as never,
12481238 role: "user",
12491239 text: "newer project user message",
1250− turnId: null,
1240+ runId: null,
12511241 createdAt: "2026-03-09T10:05:00.000Z",
12521242 updatedAt: "2026-03-09T10:05:00.000Z",
12531243 streaming: false,
apps/web/src/components/Sidebar.logic.ts+8−8
@@ -10,7 +10,7 @@ import { · 9 unmodified lines
1010 import type { SidebarThreadSummary, Thread } from "../types";
1111 import type { ThreadRouteTarget } from "../threadRoutes";
1212 import { cn } from "../lib/utils";
13−import { isLatestTurnSettled } from "../session-logic";
13+import { isLatestRunSettled } from "../session-logic";
1414 import { resolveServerBackedAppStageLabel } from "../branding.logic";
1515
1616 export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
@@ -141,8 +141,8 @@ type ThreadStatusInput = Pick< · 124 unmodified lines
141141 | "hasPendingApprovals"
142142 | "hasPendingUserInput"
143143 | "interactionMode"
144− | "latestTurn"
145− | "session"
144+ | "latestRun"
145+ | "runtime"
146146 > & {
147147 lastVisitedAt?: string | undefined;
148148 };
@@ -240,8 +240,8 @@ export function useThreadJumpHintVisibility(): { · 91 unmodified lines
240240 }
241241
242242 export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {
243− if (!thread.latestTurn?.completedAt) return false;
244− const completedAt = Date.parse(thread.latestTurn.completedAt);
243+ if (!thread.latestRun?.completedAt) return false;
244+ const completedAt = Date.parse(thread.latestRun.completedAt);
245245 if (Number.isNaN(completedAt)) return false;
246246 if (!thread.lastVisitedAt) return false;
247247
@@ -594,7 +594,7 @@ export function resolveThreadStatusPill(input: { · 346 unmodified lines
594594 };
595595 }
596596
597− if (thread.session?.status === "running") {
597+ if (thread.runtime?.status === "running" || thread.runtime?.status === "waiting") {
598598 return {
599599 label: "Working",
600600 colorClass: "text-sky-600 dark:text-sky-300/80",
@@ -603,7 +603,7 @@ export function resolveThreadStatusPill(input: { · 2 unmodified lines
603603 };
604604 }
605605
606− if (thread.session?.status === "starting") {
606+ if (thread.runtime?.status === "starting" || thread.runtime?.status === "queued") {
607607 return {
608608 label: "Connecting",
609609 colorClass: "text-sky-600 dark:text-sky-300/80",
@@ -615,7 +615,7 @@ export function resolveThreadStatusPill(input: { · 5 unmodified lines
615615 const hasPlanReadyPrompt =
616616 !thread.hasPendingUserInput &&
617617 thread.interactionMode === "plan" &&
618− isLatestTurnSettled(thread.latestTurn, thread.session) &&
618+ isLatestRunSettled(thread.latestRun, thread.runtime) &&
619619 thread.hasActionableProposedPlan;
620620 if (hasPlanReadyPrompt) {
621621 return {
apps/web/src/components/Sidebar.tsx+5−4
@@ -442,7 +442,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr · 441 unmodified lines
442442 [discoveredPorts, navigateToThread, openPreview, threadRef],
443443 );
444444 const isThreadRunning =
445− thread.session?.status === "running" && thread.session.activeTurnId != null;
445+ thread.runtime?.status === "running" && thread.runtime.activeRunId != null;
446446 const threadStatus = resolveThreadStatusPill({
447447 thread: {
448448 ...thread,
@@ -1777,8 +1777,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 1328 unmodified lines
17771777 );
17781778
17791779 if (clicked === "mark-unread") {
1780− for (const { threadKey, thread } of selectedThreadEntries) {
1781− markThreadUnread(threadKey, thread.latestTurn?.completedAt);
1780+ for (const threadKey of threadKeys) {
1781+ const thread = sidebarThreadByKeyRef.current.get(threadKey);
1782+ markThreadUnread(threadKey, thread?.latestRun?.completedAt);
17821783 }
17831784 clearSelection();
17841785 return;
@@ -2155,7 +2156,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 370 unmodified lines
21552156 }
21562157
21572158 if (clicked === "mark-unread") {
2158− markThreadUnread(threadKey, thread.latestTurn?.completedAt);
2159+ markThreadUnread(threadKey, thread.latestRun?.completedAt);
21592160 return;
21602161 }
21612162 if (clicked === "copy-path") {
apps/web/src/components/chat/ChangedFilesTree.test.tsx+3−3
@@ -1,4 +1,4 @@
1−import { TurnId } from "@t3tools/contracts";
1+import { RunId } from "@t3tools/contracts";
22 import { renderToStaticMarkup } from "react-dom/server";
33 import { describe, expect, it } from "vite-plus/test";
44
@@ -142,7 +142,7 @@ describe("ChangedFilesTree", () => { · 137 unmodified lines
142142 ({ files, visibleLabels, hiddenLabels }) => {
143143 const markup = renderToStaticMarkup(
144144 <ChangedFilesTree
145− turnId={TurnId.make("turn-1")}
145+ runId={RunId.make("turn-1")}
146146 files={files}
147147 allDirectoriesExpanded={false}
148148 resolvedTheme="light"
@@ -218,7 +218,7 @@ describe("ChangedFilesTree", () => { · 69 unmodified lines
218218 ({ files, visibleLabels }) => {
219219 const markup = renderToStaticMarkup(
220220 <ChangedFilesTree
221− turnId={TurnId.make("turn-1")}
221+ runId={RunId.make("turn-1")}
222222 files={files}
223223 allDirectoriesExpanded
224224 resolvedTheme="light"
apps/web/src/components/chat/ChangedFilesTree.tsx+27−55
@@ -1,4 +1,4 @@
1−import { type TurnId } from "@t3tools/contracts";
1+import { type RunId } from "@t3tools/contracts";
22 import { memo, useCallback, useMemo, useState } from "react";
33 import { type TurnDiffFileChange } from "../../types";
44 import {
@@ -28,7 +28,7 @@ import { · 23 unmodified lines
2828 const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};
2929
3030 export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
31− turnId: TurnId;
31+ runId: RunId;
3232 files: ReadonlyArray<TurnDiffFileChange>;
3333 expanded: boolean;
3434 showCompactPreview: boolean;
@@ -36,10 +36,10 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { · 1 unmodified line
3636 resolvedTheme: "light" | "dark";
3737 onExpandedChange: (expanded: boolean) => void;
3838 onToggleAllDirectories: () => void;
39− onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
39+ onOpenTurnDiff: (runId: RunId, filePath?: string) => void;
4040 }) {
4141 const {
42− turnId,
42+ runId,
4343 files,
4444 expanded,
4545 showCompactPreview,
@@ -100,57 +100,29 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { · 54 unmodified lines
100100 </span>
101101 </button>
102102 <div className="flex items-center gap-1.5">
103− {expanded ? (
104− <Tooltip>
105− <TooltipTrigger
106− render={
107− <Button
108− type="button"
109− size="icon-xs"
110− variant="outline"
111− className="!size-[22px]"
112− aria-label={
113− allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"
114− }
115− data-scroll-anchor-ignore
116− onClick={onToggleAllDirectories}
117− />
118− }
119− >
120− {allDirectoriesExpanded ? (
121− <ChevronsDownUpIcon className="size-3" />
122− ) : (
123− <ChevronsUpDownIcon className="size-3" />
124− )}
125− </TooltipTrigger>
126− <TooltipPopup side="top">
127− {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"}
128− </TooltipPopup>
129− </Tooltip>
130− ) : null}
131− <Tooltip>
132− <TooltipTrigger
133− render={
134− <Button
135− type="button"
136− size="xs"
137− variant="outline"
138− aria-label="Open diff"
139− onClick={() => onOpenTurnDiff(turnId, files[0]?.path)}
140− />
141− }
142− >
143− <FileDiffIcon className="size-3" />
144− <span className="hidden sm:inline">Open diff</span>
145− </TooltipTrigger>
146− <TooltipPopup side="top">Open the full diff</TooltipPopup>
147− </Tooltip>
103+ <Button
104+ type="button"
105+ size="xs"
106+ variant="outline"
107+ data-scroll-anchor-ignore
108+ onClick={onToggleAllDirectories}
109+ >
110+ {allDirectoriesExpanded ? "Collapse all" : "Expand all"}
111+ </Button>
112+ <Button
113+ type="button"
114+ size="xs"
115+ variant="outline"
116+ onClick={() => onOpenTurnDiff(runId, files[0]?.path)}
117+ >
118+ View diff
119+ </Button>
148120 </div>
149121 </div>
150122 {expanded ? (
151123 <ChangedFilesTree
152− key={`changed-files-tree:${turnId}`}
153− turnId={turnId}
124+ key={`changed-files-tree:${runId}`}
125+ runId={runId}
154126 files={files}
155127 allDirectoriesExpanded={allDirectoriesExpanded}
156128 resolvedTheme={resolvedTheme}
@@ -202,13 +174,13 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { · 45 unmodified lines
202174 });
203175
204176 export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
205− turnId: TurnId;
177+ runId: RunId;
206178 files: ReadonlyArray<TurnDiffFileChange>;
207179 allDirectoriesExpanded: boolean;
208180 resolvedTheme: "light" | "dark";
209− onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
181+ onOpenTurnDiff: (runId: RunId, filePath?: string) => void;
210182 }) {
211− const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, turnId } = props;
183+ const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, runId } = props;
212184 const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]);
213185 const directoryPathsKey = useMemo(
214186 () => collectDirectoryPaths(treeNodes).join("\u0000"),
@@ -293,7 +265,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { · 78 unmodified lines
293265 type="button"
294266 className="group flex w-full items-center gap-1.5 rounded-xl py-1 pr-3 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
295267 style={{ paddingLeft: `${leftPadding}px` }}
296− onClick={() => onOpenTurnDiff(turnId, node.path)}
268+ onClick={() => onOpenTurnDiff(runId, node.path)}
297269 >
298270 {hasDirectoryNodes || depth > 0 ? (
299271 <span aria-hidden="true" className="size-3.5 shrink-0" />
apps/web/src/components/chat/ChatComposer.tsx+40−16
@@ -1,5 +1,4 @@
11 import type {
2− ApprovalRequestId,
32 EnvironmentId,
43 ModelSelection,
54 PreviewAnnotationPayload,
@@ -10,7 +9,8 @@ import type { · 4 unmodified lines
109 ScopedThreadRef,
1110 ServerProvider,
1211 ThreadId,
13− TurnId,
12+ RunId,
13+ RuntimeRequestId,
1414 } from "@t3tools/contracts";
1515 import {
1616 ProviderDriverKind,
@@ -548,13 +548,13 @@ export interface ChatComposerProps { · 531 unmodified lines
548548 activePendingIsResponding: boolean;
549549 activePendingDraftAnswers: Record<string, PendingUserInputDraftAnswer>;
550550 activePendingQuestionIndex: number;
551− respondingRequestIds: ApprovalRequestId[];
551+ respondingRequestIds: RuntimeRequestId[];
552552
553553 // Plan
554554 showPlanFollowUpPrompt: boolean;
555555 activeProposedPlan: Thread["proposedPlans"][number] | null;
556− activePlan: { turnId?: TurnId } | null;
557− sidebarProposedPlan: { turnId?: TurnId } | null;
556+ activePlan: { runId?: RunId | null } | null;
557+ sidebarProposedPlan: { runId?: RunId | null } | null;
558558 planSidebarLabel: string;
559559 planSidebarOpen: boolean;
560560
@@ -569,7 +569,7 @@ export interface ChatComposerProps { · 8 unmodified lines
569569 activeThreadModelSelection: ModelSelection | null | undefined;
570570
571571 // Context window
572− activeThreadActivities: Thread["activities"] | undefined;
572+ activeThreadWorkEntries: Thread["workEntries"] | undefined;
573573
574574 // Misc
575575 resolvedTheme: "light" | "dark";
@@ -585,12 +585,16 @@ export interface ChatComposerProps { · 9 unmodified lines
585585 composerElementContextsRef: React.RefObject<ElementContextDraft[]>;
586586 composerRef: React.RefObject<ChatComposerHandle | null>;
587587
588+ // Scroll
589+ shouldAutoScrollRef: React.RefObject<boolean>;
590+ scheduleStickToBottom: () => void;
591+
588592 // Callbacks
589593 onSend: (e?: { preventDefault: () => void }) => void;
590594 onInterrupt: () => void;
591595 onImplementPlanInNewThread: () => void;
592596 onRespondToApproval: (
593− requestId: ApprovalRequestId,
597+ requestId: RuntimeRequestId,
594598 decision: ProviderApprovalDecision,
595599 ) => Promise<unknown>;
596600 onSelectActivePendingUserInputOption: (questionId: string, optionLabel: string) => void;
@@ -662,7 +666,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 65 unmodified lines
662666 providerStatuses,
663667 activeProjectDefaultModelSelection,
664668 activeThreadModelSelection,
665− activeThreadActivities,
669+ activeThreadWorkEntries,
666670 resolvedTheme,
667671 settings,
668672 keybindings,
@@ -673,6 +677,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 4 unmodified lines
673677 composerImagesRef,
674678 composerTerminalContextsRef,
675679 composerElementContextsRef,
680+ shouldAutoScrollRef,
681+ scheduleStickToBottom,
676682 onSend,
677683 onInterrupt,
678684 onImplementPlanInNewThread,
@@ -754,7 +760,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 75 unmodified lines
754760 );
755761 const selectedProviderByThreadId = composerDraft.activeProvider ?? null;
756762 const threadProvider =
757− activeThread?.session?.providerInstanceId ??
763+ activeThread?.runtime?.providerInstanceId ??
758764 activeThreadModelSelection?.instanceId ??
759765 activeProjectDefaultModelSelection?.instanceId ??
760766 null;
@@ -772,7 +778,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 11 unmodified lines
772778 const lockedContinuationGroupKey = useMemo((): string | null => {
773779 if (!lockedProvider || !activeThread) return null;
774780 const lockedInstanceId =
775− activeThread.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId;
781+ activeThread.runtime?.providerInstanceId ?? activeThreadModelSelection?.instanceId;
776782 if (!lockedInstanceId) return null;
777783 return (
778784 providerInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId)
@@ -798,7 +804,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 19 unmodified lines
798804 const selectedInstanceId = useMemo<ProviderInstanceId>(() => {
799805 const candidates: Array<string | null | undefined> = [
800806 composerDraft.activeProvider,
801− activeThread?.session?.providerInstanceId,
807+ activeThread?.runtime?.providerInstanceId,
802808 activeThreadModelSelection?.instanceId,
803809 activeProjectDefaultModelSelection?.instanceId,
804810 ];
@@ -835,7 +841,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 30 unmodified lines
835841 );
836842 }, [
837843 activeProjectDefaultModelSelection?.instanceId,
838− activeThread?.session?.providerInstanceId,
844+ activeThread?.runtime?.providerInstanceId,
839845 activeThreadModelSelection?.instanceId,
840846 composerDraft.activeProvider,
841847 lockedContinuationGroupKey,
@@ -939,8 +945,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 97 unmodified lines
939945 // Context window
940946 // ------------------------------------------------------------------
941947 const activeContextWindow = useMemo(
942− () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []),
943− [activeThreadActivities],
948+ () => deriveLatestContextWindowSnapshot(activeThreadWorkEntries ?? []),
949+ [activeThreadWorkEntries],
944950 );
945951 const activeThreadProviderDisplayName = useMemo(() => {
946952 if (!activeThreadModelSelection) return null;
@@ -987,6 +993,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 40 unmodified lines
987993 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
988994 const composerFormRef = useRef<HTMLFormElement>(null);
989995 const composerSurfaceRef = useRef<HTMLDivElement>(null);
996+ const composerFormHeightRef = useRef(0);
990997 const composerSelectLockRef = useRef(false);
991998 const composerMenuOpenRef = useRef(false);
992999 const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);
@@ -1441,12 +1448,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 448 unmodified lines
14411448 };
14421449 };
14431450
1451+ composerFormHeightRef.current = composerForm.getBoundingClientRect().height;
14441452 const initialCompactness = measureFooterCompactness();
14451453 setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact);
14461454 setIsComposerFooterCompact(initialCompactness.footerCompact);
14471455 if (typeof ResizeObserver === "undefined") return;
14481456
1449− const observer = new ResizeObserver(() => {
1457+ const observer = new ResizeObserver((entries) => {
1458+ const [entry] = entries;
1459+ if (!entry) return;
14501460 const nextCompactness = measureFooterCompactness();
14511461 setIsComposerPrimaryActionsCompact((previous) =>
14521462 previous === nextCompactness.primaryActionsCompact
@@ -1456,13 +1466,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 3 unmodified lines
14561466 setIsComposerFooterCompact((previous) =>
14571467 previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact,
14581468 );
1469+ const nextHeight = entry.contentRect.height;
1470+ const previousHeight = composerFormHeightRef.current;
1471+ composerFormHeightRef.current = nextHeight;
1472+ if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return;
1473+ if (!shouldAutoScrollRef.current) return;
1474+ scheduleStickToBottom();
14591475 });
14601476
14611477 observer.observe(composerForm);
14621478 return () => {
14631479 observer.disconnect();
14641480 };
1465− }, [activeThreadId, composerFooterActionLayoutKey, composerFooterHasWideActions]);
1481+ }, [
1482+ activeThreadId,
1483+ composerFooterActionLayoutKey,
1484+ composerFooterHasWideActions,
1485+ scheduleStickToBottom,
1486+ shouldAutoScrollRef,
1487+ ]);
14661488
14671489 // ------------------------------------------------------------------
14681490 // Image persist effect
@@ -2752,6 +2774,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 1283 unmodified lines
27522774 <ComposerPendingApprovalActions
27532775 requestId={activePendingApproval.requestId}
27542776 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
2777+ canRespond={activePendingApproval.responseCapability === "live"}
27552778 onRespondToApproval={onRespondToApproval}
27562779 />
27572780 </div>
@@ -3108,6 +3131,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 350 unmodified lines
31083131 <ComposerPendingApprovalActions
31093132 requestId={activePendingApproval.requestId}
31103133 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
3134+ canRespond={activePendingApproval.responseCapability === "live"}
31113135 onRespondToApproval={onRespondToApproval}
31123136 />
31133137 </div>
apps/web/src/components/chat/ComposerPendingApprovalActions.tsx+9−7
@@ -1,12 +1,13 @@
1−import { type ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts";
1+import { type RuntimeRequestId, type ProviderApprovalDecision } from "@t3tools/contracts";
22 import { memo } from "react";
33 import { Button } from "../ui/button";
44
55 interface ComposerPendingApprovalActionsProps {
6− requestId: ApprovalRequestId;
6+ requestId: RuntimeRequestId;
77 isResponding: boolean;
8+ canRespond: boolean;
89 onRespondToApproval: (
9− requestId: ApprovalRequestId,
10+ requestId: RuntimeRequestId,
1011 decision: ProviderApprovalDecision,
1112 ) => Promise<unknown>;
1213 }
@@ -14,6 +15,7 @@ interface ComposerPendingApprovalActionsProps { · 1 unmodified line
1415 export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({
1516 requestId,
1617 isResponding,
18+ canRespond,
1719 onRespondToApproval,
1820 }: ComposerPendingApprovalActionsProps) {
1921 return (
@@ -21,7 +23,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro · 1 unmodified line
2123 <Button
2224 size="sm"
2325 variant="ghost"
24− disabled={isResponding}
26+ disabled={isResponding || !canRespond}
2527 onClick={() => void onRespondToApproval(requestId, "cancel")}
2628 >
2729 Cancel turn
@@ -29,7 +31,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro · 1 unmodified line
2931 <Button
3032 size="sm"
3133 variant="destructive-outline"
32− disabled={isResponding}
34+ disabled={isResponding || !canRespond}
3335 onClick={() => void onRespondToApproval(requestId, "decline")}
3436 >
3537 Decline
@@ -37,7 +39,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro · 1 unmodified line
3739 <Button
3840 size="sm"
3941 variant="outline"
40− disabled={isResponding}
42+ disabled={isResponding || !canRespond}
4143 onClick={() => void onRespondToApproval(requestId, "acceptForSession")}
4244 >
4345 Always allow this session
@@ -45,7 +47,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro · 1 unmodified line
4547 <Button
4648 size="sm"
4749 variant="default"
48− disabled={isResponding}
50+ disabled={isResponding || !canRespond}
4951 onClick={() => void onRespondToApproval(requestId, "accept")}
5052 >
5153 Approve once
apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx+5−11
@@ -32,17 +32,11 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova · 31 unmodified lines
3232 <span className="text-xs text-muted-foreground">1/{pendingCount}</span>
3333 ) : null}
3434 </div>
35− {approval.detail ? (
36− <div className="mt-3 rounded-lg border border-border/65 bg-background/70 p-3">
37− <p className="text-xs font-medium text-muted-foreground">{detailLabel}</p>
38− <pre
39− aria-label={detailLabel}
40− className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-foreground"
41− data-approval-detail="complete"
42− >
43− {approval.detail}
44− </pre>
45− </div>
35+ {approval.responseCapability === "not_resumable" ? (
36+ <p className="mt-2 text-xs text-muted-foreground">
37+ This request belonged to a provider process that is no longer available. Interrupt or
38+ restart the run to continue.
39+ </p>
4640 ) : null}
4741 </div>
4842 );
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+17−7
@@ -1,4 +1,4 @@
1−import { type ApprovalRequestId } from "@t3tools/contracts";
1+import { type RuntimeRequestId } from "@t3tools/contracts";
22 import { memo, useEffect, useEffectEvent, useRef, useState } from "react";
33 import { type PendingUserInput } from "../../session-logic";
44 import {
@@ -10,7 +10,7 @@ import { cn } from "~/lib/utils"; · 5 unmodified lines
1010
1111 interface PendingUserInputPanelProps {
1212 pendingUserInputs: PendingUserInput[];
13− respondingRequestIds: ApprovalRequestId[];
13+ respondingRequestIds: RuntimeRequestId[];
1414 answers: Record<string, PendingUserInputDraftAnswer>;
1515 questionIndex: number;
1616 onToggleOption: (questionId: string, optionLabel: string) => void;
@@ -59,6 +59,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 42 unmodified lines
5959 }) {
6060 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);
6161 const activeQuestion = progress.activeQuestion;
62+ const canRespond = prompt.responseCapability === "live";
63+ const responseDisabled = isResponding || !canRespond;
6264 const autoAdvanceTimerRef = useRef<number | null>(null);
6365 const onAdvanceRef = useRef(onAdvance);
6466 const [optimisticSingleSelect, setOptimisticSingleSelect] = useState<{
@@ -120,7 +122,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 55 unmodified lines
120122 // outside editable fields. Multi-select prompts toggle options in place; single-
121123 // select prompts keep the existing auto-advance behavior.
122124 useEffect(() => {
123− if (!activeQuestion || isResponding) return;
125+ if (!activeQuestion || responseDisabled) return;
124126 const handler = (event: globalThis.KeyboardEvent) => {
125127 if (event.metaKey || event.ctrlKey || event.altKey) return;
126128 const target = event.target;
@@ -144,7 +146,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 17 unmodified lines
144146 };
145147 document.addEventListener("keydown", handler);
146148 return () => document.removeEventListener("keydown", handler);
147− }, [activeQuestion, isResponding]);
149+ }, [activeQuestion, responseDisabled]);
148150
149151 if (!activeQuestion) {
150152 return null;
@@ -165,6 +167,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 14 unmodified lines
165167 ) : null}
166168 </div>
167169 <p className="text-sm text-foreground/90">{activeQuestion.question}</p>
170+ {!canRespond ? (
171+ <p className="mt-1 text-xs text-muted-foreground">
172+ This request belonged to a provider process that is no longer available. Interrupt or
173+ restart the run to continue.
174+ </p>
175+ ) : null}
168176 {activeQuestion.multiSelect ? (
169177 <p className="mt-1 text-xs text-muted-foreground/65">Select one or more options.</p>
170178 ) : null}
@@ -182,8 +190,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 11 unmodified lines
182190 isSelected
183191 ? "border-primary/30 bg-primary/8 text-foreground"
184192 : "border-transparent bg-muted/22 text-foreground/85 hover:border-border/45 hover:bg-muted/34",
185− isResponding && "opacity-50 cursor-not-allowed",
186− !isResponding && "cursor-pointer",
193+ responseDisabled && "opacity-50 cursor-not-allowed",
194+ !responseDisabled && "cursor-pointer",
187195 );
188196 const content = (
189197 <>
@@ -211,8 +219,10 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 21 unmodified lines
211219 <button
212220 key={`${activeQuestion.id}:${option.label}`}
213221 type="button"
214− disabled={isResponding}
222+ disabled={responseDisabled}
223+ aria-disabled={responseDisabled}
215224 onClick={() => {
225+ if (responseDisabled) return;
216226 handleOptionSelection(activeQuestion.id, option.label);
217227 }}
218228 className={className}
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+128−55
@@ -272,7 +272,7 @@ describe("deriveMessagesTimelineRows", () => { · 271 unmodified lines
272272 id: "user-1" as never,
273273 role: "user",
274274 text: "Write a poem",
275− turnId: null,
275+ runId: null,
276276 createdAt: "2026-01-01T00:00:00Z",
277277 updatedAt: "2026-01-01T00:00:00Z",
278278 streaming: false,
@@ -286,7 +286,7 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines
286286 id: "assistant-thought" as never,
287287 role: "assistant",
288288 text: "I should ground this first.",
289− turnId: "turn-1" as never,
289+ runId: "turn-1" as never,
290290 createdAt: "2026-01-01T00:00:10Z",
291291 updatedAt: "2026-01-01T00:00:11Z",
292292 streaming: false,
@@ -300,14 +300,14 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines
300300 id: "assistant-final" as never,
301301 role: "assistant",
302302 text: "Here is the poem.",
303− turnId: "turn-1" as never,
303+ runId: "turn-1" as never,
304304 createdAt: "2026-01-01T00:00:20Z",
305305 updatedAt: "2026-01-01T00:00:30Z",
306306 streaming: false,
307307 },
308308 },
309309 ],
310− expandedTurnIds: new Set(["turn-1" as never]),
310+ expandedRunIds: new Set(["turn-1" as never]),
311311 isWorking: false,
312312 activeTurnStartedAt: null,
313313 turnDiffSummaryByAssistantMessageId: new Map(),
@@ -335,7 +335,7 @@ describe("deriveMessagesTimelineRows", () => { · 21 unmodified lines
335335 id: "assistant-one" as never,
336336 role: "assistant",
337337 text: "Earlier response.",
338− turnId: "turn-1" as never,
338+ runId: "turn-1" as never,
339339 createdAt: "2026-01-01T00:00:10Z",
340340 updatedAt: "2026-01-01T00:00:11Z",
341341 streaming: false,
@@ -349,16 +349,16 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines
349349 id: "assistant-two" as never,
350350 role: "assistant",
351351 text: "Active response.",
352− turnId: "turn-2" as never,
352+ runId: "turn-2" as never,
353353 createdAt: "2026-01-01T00:00:20Z",
354354 updatedAt: "2026-01-01T00:00:30Z",
355355 streaming: false,
356356 },
357357 },
358358 ],
359− latestTurn: {
360− turnId: "turn-2" as never,
361− state: "running",
359+ latestRun: {
360+ runId: "turn-2" as never,
361+ status: "running",
362362 startedAt: "2026-01-01T00:00:19Z",
363363 completedAt: null,
364364 },
@@ -379,7 +379,7 @@ describe("deriveMessagesTimelineRows", () => { · 14 unmodified lines
379379
380380 it("projects assistant diff summaries and user revert counts onto the affected rows", () => {
381381 const assistantTurnDiffSummary = {
382− turnId: "turn-1" as never,
382+ runId: "turn-1" as never,
383383 completedAt: "2026-01-01T00:00:30Z",
384384 assistantMessageId: "assistant-1" as never,
385385 checkpointTurnCount: 2,
@@ -398,7 +398,7 @@ describe("deriveMessagesTimelineRows", () => { · 12 unmodified lines
398398 id: "user-1" as never,
399399 role: "user",
400400 text: "Do the thing",
401− turnId: null,
401+ runId: null,
402402 createdAt: "2026-01-01T00:00:00Z",
403403 updatedAt: "2026-01-01T00:00:00Z",
404404 streaming: false,
@@ -412,7 +412,7 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines
412412 id: "assistant-1" as never,
413413 role: "assistant",
414414 text: "Done",
415− turnId: "turn-1" as never,
415+ runId: "turn-1" as never,
416416 createdAt: "2026-01-01T00:00:20Z",
417417 updatedAt: "2026-01-01T00:00:30Z",
418418 streaming: false,
@@ -450,7 +450,7 @@ describe("deriveMessagesTimelineRows", () => { · 31 unmodified lines
450450 id: "user-1" as never,
451451 role: "user" as const,
452452 text: "Build it",
453− turnId: null,
453+ runId: null,
454454 createdAt: "2026-01-01T00:00:00Z",
455455 updatedAt: "2026-01-01T00:00:00Z",
456456 streaming: false,
@@ -464,7 +464,7 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines
464464 id: "assistant-thought" as never,
465465 role: "assistant" as const,
466466 text: "Looking around first.",
467− turnId: "turn-1" as never,
467+ runId: "turn-1" as never,
468468 createdAt: "2026-01-01T00:00:05Z",
469469 updatedAt: "2026-01-01T00:00:06Z",
470470 streaming: false,
@@ -477,7 +477,7 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines
477477 entry: {
478478 id: "work-1",
479479 createdAt: "2026-01-01T00:00:08Z",
480− turnId: "turn-1" as never,
480+ runId: "turn-1" as never,
481481 label: "Ran command",
482482 tone: "tool" as const,
483483 },
@@ -490,7 +490,7 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines
490490 id: "assistant-final" as never,
491491 role: "assistant" as const,
492492 text: "Done",
493− turnId: "turn-1" as never,
493+ runId: "turn-1" as never,
494494 createdAt: "2026-01-01T00:00:20Z",
495495 updatedAt: "2026-01-01T00:00:22Z",
496496 streaming: false,
@@ -510,7 +510,7 @@ describe("deriveMessagesTimelineRows", () => { · 13 unmodified lines
510510 (row): row is Extract<(typeof collapsedRows)[number], { kind: "turn-fold" }> =>
511511 row.kind === "turn-fold",
512512 );
513− expect(foldRow?.turnId).toBe("turn-1");
513+ expect(foldRow?.runId).toBe("turn-1");
514514 expect(foldRow?.expanded).toBe(false);
515515 // User message boundary (00:00:00) → terminal message updatedAt (00:00:22).
516516 expect(foldRow?.label).toBe("Worked for 22s");
@@ -522,7 +522,7 @@ describe("deriveMessagesTimelineRows", () => { · 5 unmodified lines
522522
523523 const expandedRows = deriveMessagesTimelineRows({
524524 timelineEntries,
525− expandedTurnIds: new Set(["turn-1" as never]),
525+ expandedRunIds: new Set(["turn-1" as never]),
526526 isWorking: false,
527527 activeTurnStartedAt: null,
528528 turnDiffSummaryByAssistantMessageId: new Map(),
@@ -556,7 +556,7 @@ describe("deriveMessagesTimelineRows", () => { · 27 unmodified lines
556556 id: "user-1" as never,
557557 role: "user" as const,
558558 text: "do it once more",
559− turnId: null,
559+ runId: null,
560560 createdAt: "2026-01-01T00:00:00Z",
561561 updatedAt: "2026-01-01T00:00:00Z",
562562 streaming: false,
@@ -570,7 +570,7 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines
570570 id: "assistant-commentary" as never,
571571 role: "assistant" as const,
572572 text: "Kicking off call 1.",
573− turnId: "turn-1" as never,
573+ runId: "turn-1" as never,
574574 createdAt: "2026-01-01T00:00:09Z",
575575 updatedAt: "2026-01-01T00:00:09Z",
576576 streaming: false,
@@ -583,7 +583,7 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines
583583 entry: {
584584 id: "work-1",
585585 createdAt: "2026-01-01T00:00:12Z",
586− turnId: "turn-1" as never,
586+ runId: "turn-1" as never,
587587 label: "Ran command",
588588 tone: "tool" as const,
589589 },
@@ -596,7 +596,7 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines
596596 id: "user-2" as never,
597597 role: "user" as const,
598598 text: "actually do 15",
599− turnId: null,
599+ runId: null,
600600 createdAt: "2026-01-01T00:00:14Z",
601601 updatedAt: "2026-01-01T00:00:14Z",
602602 streaming: false,
@@ -610,16 +610,16 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines
610610 id: "assistant-next" as never,
611611 role: "assistant" as const,
612612 text: "One down — adjusting.",
613− turnId: "turn-2" as never,
613+ runId: "turn-2" as never,
614614 createdAt: "2026-01-01T00:00:17Z",
615615 updatedAt: "2026-01-01T00:00:17Z",
616616 streaming: true,
617617 },
618618 },
619619 ],
620− latestTurn: {
621− turnId: "turn-2" as never,
622− state: "running",
620+ latestRun: {
621+ runId: "turn-2" as never,
622+ status: "running",
623623 startedAt: "2026-01-01T00:00:14Z",
624624 completedAt: null,
625625 },
@@ -634,7 +634,7 @@ describe("deriveMessagesTimelineRows", () => { · 8 unmodified lines
634634 row.kind === "turn-fold",
635635 );
636636 // User message (00:00:00) → trailing work entry (00:00:12).
637− expect(foldRow?.turnId).toBe("turn-1");
637+ expect(foldRow?.runId).toBe("turn-1");
638638 expect(foldRow?.label).toBe("Worked for 12s");
639639 });
640640
@@ -648,15 +648,15 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines
648648 entry: {
649649 id: "work-1",
650650 createdAt: "2026-01-01T00:00:05Z",
651− turnId: "turn-1" as never,
651+ runId: "turn-1" as never,
652652 label: "Ran command",
653653 tone: "tool" as const,
654654 },
655655 },
656656 ],
657− latestTurn: {
658− turnId: "turn-1" as never,
659− state: "interrupted",
657+ latestRun: {
658+ runId: "turn-1" as never,
659+ status: "interrupted",
660660 startedAt: "2026-01-01T00:00:00Z",
661661 completedAt: "2026-01-01T00:00:47Z",
662662 },
@@ -669,15 +669,88 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines
669669 expect(rows).toEqual([
670670 expect.objectContaining({
671671 kind: "turn-fold",
672− turnId: "turn-1",
672+ runId: "turn-1",
673673 label: "You stopped after 47s",
674674 expanded: false,
675675 }),
676676 ]);
677677 });
678678
679+ it("keeps interruption request, intervening work, and result visible in order", () => {
680+ const runId = "turn-1" as never;
681+ const interruptEvent = (type: "run_interrupt_request" | "run_interrupt_result") => ({
682+ position: type === "run_interrupt_request" ? 0 : 2,
683+ visibility: "local" as const,
684+ sourceThreadId: "thread-1" as never,
685+ sourceItemId: `item-${type}` as never,
686+ item: {
687+ id: `item-${type}`,
688+ threadId: "thread-1",
689+ runId,
690+ nodeId: null,
691+ providerThreadId: null,
692+ providerTurnId: null,
693+ nativeItemRef: null,
694+ parentItemId: null,
695+ ordinal: type === "run_interrupt_request" ? 0 : 2,
696+ status: "completed",
697+ title: null,
698+ startedAt: null,
699+ completedAt: null,
700+ updatedAt: {},
701+ type,
702+ message: type === "run_interrupt_request" ? "Stopping" : "Stopped",
703+ },
704+ });
705+ const rows = deriveMessagesTimelineRows({
706+ timelineEntries: [
707+ {
708+ id: "interrupt-request",
709+ kind: "event",
710+ createdAt: "2026-01-01T00:00:01Z",
711+ projectedItem: interruptEvent("run_interrupt_request") as never,
712+ },
713+ {
714+ id: "work-entry",
715+ kind: "work",
716+ createdAt: "2026-01-01T00:00:02Z",
717+ entry: {
718+ id: "work-1",
719+ createdAt: "2026-01-01T00:00:02Z",
720+ runId,
721+ label: "Finishing tool output",
722+ tone: "tool",
723+ },
724+ },
725+ {
726+ id: "interrupt-result",
727+ kind: "event",
728+ createdAt: "2026-01-01T00:00:03Z",
729+ projectedItem: interruptEvent("run_interrupt_result") as never,
730+ },
731+ ],
732+ latestRun: {
733+ runId,
734+ status: "interrupted",
735+ startedAt: "2026-01-01T00:00:00Z",
736+ completedAt: "2026-01-01T00:00:03Z",
737+ },
738+ isWorking: false,
739+ activeTurnStartedAt: null,
740+ turnDiffSummaryByAssistantMessageId: new Map(),
741+ revertTurnCountByUserMessageId: new Map(),
742+ });
743+
744+ expect(rows.map((row) => row.id)).toEqual([
745+ "interrupt-request",
746+ "work-entry",
747+ "interrupt-result",
748+ ]);
749+ expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
750+ });
751+
679752 it("keeps the previous turn folded while a newly sent message awaits its turn", () => {
680− // Right after send, isWorking is true but latestTurn still points at the
753+ // Right after send, isWorking is true but latestRun still points at the
681754 // previous, settled turn — it must stay folded through that window.
682755 const rows = deriveMessagesTimelineRows({
683756 timelineEntries: [
@@ -688,7 +761,7 @@ describe("deriveMessagesTimelineRows", () => { · 4 unmodified lines
688761 entry: {
689762 id: "work-1",
690763 createdAt: "2026-01-01T00:00:05Z",
691− turnId: "turn-1" as never,
764+ runId: "turn-1" as never,
692765 label: "Ran command",
693766 tone: "tool" as const,
694767 },
@@ -701,7 +774,7 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines
701774 id: "assistant-final" as never,
702775 role: "assistant",
703776 text: "Done",
704− turnId: "turn-1" as never,
777+ runId: "turn-1" as never,
705778 createdAt: "2026-01-01T00:00:20Z",
706779 updatedAt: "2026-01-01T00:00:22Z",
707780 streaming: false,
@@ -715,16 +788,16 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines
715788 id: "user-followup" as never,
716789 role: "user",
717790 text: "yooo",
718− turnId: null,
791+ runId: null,
719792 createdAt: "2026-01-01T00:01:00Z",
720793 updatedAt: "2026-01-01T00:01:00Z",
721794 streaming: false,
722795 },
723796 },
724797 ],
725− latestTurn: {
726− turnId: "turn-1" as never,
727− state: "completed",
798+ latestRun: {
799+ runId: "turn-1" as never,
800+ status: "completed",
728801 startedAt: "2026-01-01T00:00:00Z",
729802 completedAt: "2026-01-01T00:00:22Z",
730803 },
@@ -755,7 +828,7 @@ describe("deriveMessagesTimelineRows", () => { · 24 unmodified lines
755828 id: "assistant-thought" as never,
756829 role: "assistant",
757830 text: "Working on it.",
758− turnId: "turn-1" as never,
831+ runId: "turn-1" as never,
759832 createdAt: "2026-01-01T00:00:05Z",
760833 updatedAt: "2026-01-01T00:00:06Z",
761834 streaming: false,
@@ -768,15 +841,15 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines
768841 entry: {
769842 id: "work-1",
770843 createdAt: "2026-01-01T00:00:08Z",
771− turnId: "turn-1" as never,
844+ runId: "turn-1" as never,
772845 label: "Ran command",
773846 tone: "tool" as const,
774847 },
775848 },
776849 ],
777− latestTurn: {
778− turnId: "turn-1" as never,
779− state: "running",
850+ latestRun: {
851+ runId: "turn-1" as never,
852+ status: "running",
780853 startedAt: "2026-01-01T00:00:00Z",
781854 completedAt: null,
782855 },
@@ -866,7 +939,7 @@ describe("deriveMessagesTimelineRows", () => { · 83 unmodified lines
866939 id: "assistant-thought" as never,
867940 role: "assistant",
868941 text: "Checking first.",
869− turnId: "turn-1" as never,
942+ runId: "turn-1" as never,
870943 createdAt: "2026-01-01T00:00:10Z",
871944 updatedAt: "2026-01-01T00:00:11Z",
872945 streaming: false,
@@ -880,14 +953,14 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines
880953 id: "assistant-final" as never,
881954 role: "assistant",
882955 text: "Done.",
883− turnId: "turn-1" as never,
956+ runId: "turn-1" as never,
884957 createdAt: "2026-01-01T00:00:20Z",
885958 updatedAt: "2026-01-01T00:00:30Z",
886959 streaming: false,
887960 },
888961 },
889962 ],
890− expandedTurnIds: new Set(["turn-1" as never]),
963+ expandedRunIds: new Set(["turn-1" as never]),
891964 isWorking: false,
892965 activeTurnStartedAt: null,
893966 turnDiffSummaryByAssistantMessageId: new Map(),
@@ -913,16 +986,16 @@ describe("deriveMessagesTimelineRows", () => { · 19 unmodified lines
913986 id: "assistant-thought" as never,
914987 role: "assistant",
915988 text: "Working on it.",
916− turnId: "turn-1" as never,
989+ runId: "turn-1" as never,
917990 createdAt: "2026-01-01T00:00:10Z",
918991 updatedAt: "2026-01-01T00:00:11Z",
919992 streaming: false,
920993 },
921994 },
922995 ],
923− latestTurn: {
924− turnId: "turn-1" as never,
925− state: "running",
996+ latestRun: {
997+ runId: "turn-1" as never,
998+ status: "running",
926999 startedAt: "2026-01-01T00:00:00Z",
9271000 completedAt: null,
9281001 },
@@ -1019,7 +1092,7 @@ describe("computeStableMessagesTimelineRows", () => { · 90 unmodified lines
10191092 id: "user-1" as never,
10201093 role: "user" as const,
10211094 text: "First",
1022− turnId: null,
1095+ runId: null,
10231096 createdAt: "2026-01-01T00:00:00Z",
10241097 updatedAt: "2026-01-01T00:00:00Z",
10251098 streaming: false,
@@ -1028,7 +1101,7 @@ describe("computeStableMessagesTimelineRows", () => { · 2 unmodified lines
10281101 id: "user-2" as never,
10291102 role: "user" as const,
10301103 text: "Second",
1031− turnId: null,
1104+ runId: null,
10321105 createdAt: "2026-01-01T00:00:10Z",
10331106 updatedAt: "2026-01-01T00:00:10Z",
10341107 streaming: false,
@@ -1124,7 +1197,7 @@ describe("computeStableMessagesTimelineRows", () => { · 89 unmodified lines
11241197 id: "user-1" as never,
11251198 role: "user" as const,
11261199 text: "First",
1127− turnId: null,
1200+ runId: null,
11281201 createdAt: "2026-01-01T00:00:00Z",
11291202 updatedAt: "2026-01-01T00:00:00Z",
11301203 streaming: false,
@@ -1133,7 +1206,7 @@ describe("computeStableMessagesTimelineRows", () => { · 2 unmodified lines
11331206 id: "user-2" as never,
11341207 role: "user" as const,
11351208 text: "Second",
1136− turnId: null,
1209+ runId: null,
11371210 createdAt: "2026-01-01T00:00:10Z",
11381211 updatedAt: "2026-01-01T00:00:10Z",
11391212 streaming: false,
apps/web/src/components/chat/MessagesTimeline.logic.ts+78−51
@@ -7,7 +7,12 @@ import { · 6 unmodified lines
77 type WorkLogEntry,
88 } from "../../session-logic";
99 import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
10−import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts";
10+import {
11+ type MessageId,
12+ type OrchestrationV2ProjectedTurnItem,
13+ type RunId,
14+} from "@t3tools/contracts";
15+import type { ThreadRunSummary } from "@t3tools/client-runtime/state/shell";
1116
1217 export const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
1318 export const TIMELINE_MINIMAP_ITEM_SPACING = 8;
@@ -128,9 +133,9 @@ export interface TimelineDurationMessage { · 114 unmodified lines
128133 streaming: boolean;
129134 }
130135
131−export type TimelineLatestTurn = Pick<
132− OrchestrationLatestTurn,
133− "turnId" | "state" | "startedAt" | "completedAt"
136+export type TimelineLatestRun = Pick<
137+ ThreadRunSummary,
138+ "runId" | "status" | "startedAt" | "completedAt"
134139 >;
135140
136141 export type MessagesTimelineRow =
@@ -153,7 +158,7 @@ export type MessagesTimelineRow = · 16 unmodified lines
153158 kind: "turn-fold";
154159 id: string;
155160 createdAt: string;
156− turnId: TurnId;
161+ runId: RunId;
157162 label: string;
158163 expanded: boolean;
159164 }
@@ -169,6 +174,12 @@ export type MessagesTimelineRow = · 9 unmodified lines
169174 assistantTurnDiffSummary?: TurnDiffSummary | undefined;
170175 revertTurnCount?: number | undefined;
171176 }
177+ | {
178+ kind: "event";
179+ id: string;
180+ createdAt: string;
181+ projectedItem: OrchestrationV2ProjectedTurnItem;
182+ }
172183 | {
173184 kind: "proposed-plan";
174185 id: string;
@@ -238,8 +249,8 @@ function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray<Timeli · 63 unmodified lines
238249 continue;
239250 }
240251
241− const responseKey = message.turnId
242− ? `turn:${message.turnId}`
252+ const responseKey = message.runId
253+ ? `turn:${message.runId}`
243254 : `unkeyed:${nullTurnResponseIndex}`;
244255 lastAssistantMessageIdByResponseKey.set(responseKey, message.id);
245256 }
@@ -248,7 +259,7 @@ function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray<Timeli · 2 unmodified lines
248259 }
249260
250261 interface TurnFold {
251− turnId: TurnId;
262+ runId: RunId;
252263 anchorEntryId: string;
253264 createdAt: string;
254265 hiddenEntryIds: ReadonlySet<string>;
@@ -263,18 +274,16 @@ interface TurnFold { · 8 unmodified lines
263274 * user sends a message, the previous turn is still the "active" one until the
264275 * server creates the new turn, and folding must not flicker through that window.
265276 */
266−function deriveUnsettledTurnId(
267− latestTurn: TimelineLatestTurn | null,
268− runningTurnId: TurnId | null,
269−): TurnId | null {
270− if (runningTurnId !== null) {
271− return runningTurnId;
272− }
273− if (!latestTurn) {
277+function deriveUnsettledRunId(latestRun: TimelineLatestRun | null): RunId | null {
278+ if (!latestRun) {
274279 return null;
275280 }
276− const isSettled = latestTurn.completedAt !== null && latestTurn.state !== "running";
277− return isSettled ? null : latestTurn.turnId;
281+ const isSettled =
282+ latestRun.completedAt !== null &&
283+ latestRun.status !== "running" &&
284+ latestRun.status !== "starting" &&
285+ latestRun.status !== "waiting";
286+ return isSettled ? null : latestRun.runId;
278287 }
279288
280289 /**
@@ -285,9 +294,21 @@ function deriveUnsettledTurnId( · 4 unmodified lines
285294 function deriveTurnFolds(input: {
286295 timelineEntries: ReadonlyArray<TimelineEntry>;
287296 terminalAssistantMessageIds: ReadonlySet<string>;
288− latestTurn: TimelineLatestTurn | null;
289− unsettledTurnId: TurnId | null;
297+ latestRun: TimelineLatestRun | null;
298+ unsettledRunId: RunId | null;
290299 }): ReadonlyMap<string, TurnFold> {
300+ const interruptedRunIds = new Set<RunId>();
301+ for (const entry of input.timelineEntries) {
302+ if (
303+ entry.kind === "event" &&
304+ entry.projectedItem.item.runId !== null &&
305+ (entry.projectedItem.item.type === "run_interrupt_request" ||
306+ entry.projectedItem.item.type === "run_interrupt_result")
307+ ) {
308+ interruptedRunIds.add(entry.projectedItem.item.runId);
309+ }
310+ }
311+
291312 interface TurnGroup {
292313 entries: Array<TimelineEntry>;
293314 terminalEntry: Extract<TimelineEntry, { kind: "message" }> | null;
@@ -300,7 +321,7 @@ function deriveTurnFolds(input: { · 6 unmodified lines
300321 */
301322 startBoundary: string | null;
302323 }
303− const groupsByTurnId = new Map<TurnId, TurnGroup>();
324+ const groupsByRunId = new Map<RunId, TurnGroup>();
304325
305326 let pendingUserBoundary: string | null = null;
306327 for (const entry of input.timelineEntries) {
@@ -308,16 +329,16 @@ function deriveTurnFolds(input: { · 1 unmodified line
308329 pendingUserBoundary = entry.message.createdAt;
309330 continue;
310331 }
311− const turnId =
332+ const runId =
312333 entry.kind === "message" && entry.message.role === "assistant"
313− ? (entry.message.turnId ?? null)
334+ ? (entry.message.runId ?? null)
314335 : entry.kind === "work"
315− ? (entry.entry.turnId ?? null)
336+ ? (entry.entry.runId ?? null)
316337 : null;
317− if (!turnId) {
338+ if (!runId) {
318339 continue;
319340 }
320− let group = groupsByTurnId.get(turnId);
341+ let group = groupsByRunId.get(runId);
321342 if (!group) {
322343 group = {
323344 entries: [],
@@ -329,7 +350,7 @@ function deriveTurnFolds(input: { · 5 unmodified lines
329350 startBoundary: pendingUserBoundary,
330351 };
331352 pendingUserBoundary = null;
332− groupsByTurnId.set(turnId, group);
353+ groupsByRunId.set(runId, group);
333354 }
334355 group.entries.push(entry);
335356 if (entry.kind === "message") {
@@ -343,8 +364,8 @@ function deriveTurnFolds(input: { · 7 unmodified lines
343364 }
344365
345366 const foldsByAnchorEntryId = new Map<string, TurnFold>();
346− for (const [turnId, group] of groupsByTurnId) {
347− if (turnId === input.unsettledTurnId) {
367+ for (const [runId, group] of groupsByRunId) {
368+ if (runId === input.unsettledRunId || interruptedRunIds.has(runId)) {
348369 continue;
349370 }
350371 if (group.hasStreamingMessage) {
@@ -367,16 +388,14 @@ function deriveTurnFolds(input: { · 16 unmodified lines
367388 }
368389
369390 const isLatestInterruptedTurn =
370− input.latestTurn?.turnId === turnId && input.latestTurn.state === "interrupted";
391+ input.latestRun?.runId === runId && input.latestRun.status === "interrupted";
371392 // A turn cut short by a steer leaves trailing work entries behind its
372393 // terminal message — take whichever ended last.
373394 const lastEntryEnd =
374395 lastEntry.kind === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt;
375396 const elapsedMs =
376− input.latestTurn?.turnId === turnId &&
377− input.latestTurn.startedAt &&
378− input.latestTurn.completedAt
379− ? computeElapsedMs(input.latestTurn.startedAt, input.latestTurn.completedAt)
397+ input.latestRun?.runId === runId && input.latestRun.startedAt && input.latestRun.completedAt
398+ ? computeElapsedMs(input.latestRun.startedAt, input.latestRun.completedAt)
380399 : computeElapsedMs(
381400 group.startBoundary ?? firstEntry.createdAt,
382401 maxIsoTimestamp(group.terminalEntry?.message.updatedAt ?? null, lastEntryEnd) ??
@@ -392,7 +411,7 @@ function deriveTurnFolds(input: { · 9 unmodified lines
392411 : "Worked";
393412
394413 foldsByAnchorEntryId.set(firstEntry.id, {
395− turnId,
414+ runId,
396415 anchorEntryId: firstEntry.id,
397416 createdAt: firstEntry.createdAt,
398417 hiddenEntryIds,
@@ -404,10 +423,8 @@ function deriveTurnFolds(input: { · 5 unmodified lines
404423
405424 export function deriveMessagesTimelineRows(input: {
406425 timelineEntries: ReadonlyArray<TimelineEntry>;
407− latestTurn?: TimelineLatestTurn | null;
408− runningTurnId?: TurnId | null;
409− expandedTurnIds?: ReadonlySet<TurnId>;
410− expandedWorkGroupIds?: ReadonlySet<string>;
426+ latestRun?: TimelineLatestRun | null;
427+ expandedRunIds?: ReadonlySet<RunId>;
411428 isWorking: boolean;
412429 activeTurnStartedAt: string | null;
413430 turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>;
@@ -418,19 +435,16 @@ export function deriveMessagesTimelineRows(input: { · 4 unmodified lines
418435 input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),
419436 );
420437 const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(input.timelineEntries);
421− const unsettledTurnId = deriveUnsettledTurnId(
422− input.latestTurn ?? null,
423− input.runningTurnId ?? null,
424− );
438+ const unsettledRunId = deriveUnsettledRunId(input.latestRun ?? null);
425439 const foldsByAnchorEntryId = deriveTurnFolds({
426440 timelineEntries: input.timelineEntries,
427441 terminalAssistantMessageIds,
428− latestTurn: input.latestTurn ?? null,
429− unsettledTurnId,
442+ latestRun: input.latestRun ?? null,
443+ unsettledRunId,
430444 });
431445 const collapsedEntryIds = new Set<string>();
432446 for (const fold of foldsByAnchorEntryId.values()) {
433− if (!input.expandedTurnIds?.has(fold.turnId)) {
447+ if (!input.expandedRunIds?.has(fold.runId)) {
434448 for (const entryId of fold.hiddenEntryIds) {
435449 collapsedEntryIds.add(entryId);
436450 }
@@ -447,11 +461,11 @@ export function deriveMessagesTimelineRows(input: { · 10 unmodified lines
447461 if (turnFold) {
448462 nextRows.push({
449463 kind: "turn-fold",
450− id: `turn-fold:${turnFold.turnId}`,
464+ id: `turn-fold:${turnFold.runId}`,
451465 createdAt: turnFold.createdAt,
452− turnId: turnFold.turnId,
466+ runId: turnFold.runId,
453467 label: turnFold.label,
454− expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false,
468+ expanded: input.expandedRunIds?.has(turnFold.runId) ?? false,
455469 });
456470 }
457471
@@ -527,10 +541,20 @@ export function deriveMessagesTimelineRows(input: { · 69 unmodified lines
527541 continue;
528542 }
529543
544+ if (timelineEntry.kind === "event") {
545+ nextRows.push({
546+ kind: "event",
547+ id: timelineEntry.id,
548+ createdAt: timelineEntry.createdAt,
549+ projectedItem: timelineEntry.projectedItem,
550+ });
551+ continue;
552+ }
553+
530554 const assistantTurnStillInProgress =
531555 timelineEntry.message.role === "assistant" &&
532− unsettledTurnId !== null &&
533− timelineEntry.message.turnId === unsettledTurnId;
556+ unsettledRunId !== null &&
557+ timelineEntry.message.runId === unsettledRunId;
534558
535559 const durationStart =
536560 durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt;
@@ -610,6 +634,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean · 73 unmodified lines
610634 case "proposed-plan":
611635 return a.proposedPlan === (b as typeof a).proposedPlan;
612636
637+ case "event":
638+ return a.projectedItem === (b as typeof a).projectedItem;
639+
613640 case "work":
614641 return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries);
615642
apps/web/src/components/chat/MessagesTimeline.test.tsx+116−6
@@ -176,8 +176,7 @@ function buildProps() { · 175 unmodified lines
176176 activeTurnInProgress: false,
177177 activeTurnStartedAt: null,
178178 listRef: createRef<LegendListRef | null>(),
179− latestTurn: null,
180− runningTurnId: null,
179+ latestRun: null,
181180 turnDiffSummaryByAssistantMessageId: new Map(),
182181 routeThreadKey: "environment-local:thread-1",
183182 onOpenTurnDiff: () => {},
@@ -214,7 +213,7 @@ function buildUserTimelineEntry(text: string) { · 30 unmodified lines
214213 id: MessageId.make("message-1"),
215214 role: "user" as const,
216215 text,
217− turnId: null,
216+ runId: null,
218217 createdAt: MESSAGE_CREATED_AT,
219218 updatedAt: MESSAGE_CREATED_AT,
220219 streaming: false,
@@ -527,7 +526,116 @@ describe("MessagesTimeline", () => { · 306 unmodified lines
527526 expect(markup).toContain("Work Log");
528527 });
529528
530− it("formats changed file paths from the workspace root", () => {
529+ it("renders V2 interruption lifecycle entries as standalone rows", async () => {
530+ const { MessagesTimeline } = await import("./MessagesTimeline");
531+ const markup = renderToStaticMarkup(
532+ <MessagesTimeline
533+ {...buildProps()}
534+ timelineEntries={[
535+ {
536+ id: "interrupt-request",
537+ kind: "event",
538+ createdAt: MESSAGE_CREATED_AT,
539+ projectedItem: {
540+ position: 0,
541+ visibility: "local",
542+ sourceThreadId: "thread-1",
543+ sourceItemId: "interrupt-request",
544+ item: {
545+ id: "interrupt-request",
546+ threadId: "thread-1",
547+ runId: "run-1",
548+ nodeId: null,
549+ providerThreadId: null,
550+ providerTurnId: null,
551+ nativeItemRef: null,
552+ parentItemId: null,
553+ ordinal: 0,
554+ status: "completed",
555+ title: null,
556+ startedAt: null,
557+ completedAt: null,
558+ updatedAt: {},
559+ type: "run_interrupt_request",
560+ message: "Waiting for the provider to stop.",
561+ },
562+ } as never,
563+ },
564+ ]}
565+ />,
566+ );
567+
568+ expect(markup).toContain('data-v2-item-type="run_interrupt_request"');
569+ expect(markup).toContain("Interrupt requested");
570+ expect(markup).toContain("Waiting for the provider to stop.");
571+ expect(markup).toContain('data-v2-structured-details="true"');
572+ expect(markup).toContain("Structured details");
573+ expect(markup).toContain("&quot;type&quot;: &quot;run_interrupt_request&quot;");
574+ });
575+
576+ it("keeps inherited V2 work provenance on the rendered row", async () => {
577+ const { MessagesTimeline } = await import("./MessagesTimeline");
578+ const item = {
579+ id: "command-inherited",
580+ threadId: "thread-source",
581+ runId: null,
582+ nodeId: null,
583+ providerThreadId: null,
584+ providerTurnId: null,
585+ nativeItemRef: null,
586+ parentItemId: null,
587+ ordinal: 0,
588+ status: "completed",
589+ title: null,
590+ startedAt: null,
591+ completedAt: null,
592+ updatedAt: {},
593+ type: "command_execution",
594+ input: "pwd",
595+ output: "/workspace",
596+ exitCode: 0,
597+ } as const;
598+ const projectedItem = {
599+ position: 0,
600+ visibility: "inherited",
601+ sourceThreadId: "thread-source",
602+ sourceItemId: item.id,
603+ item,
604+ } as const;
605+ const markup = renderToStaticMarkup(
606+ <MessagesTimeline
607+ {...buildProps()}
608+ timelineEntries={
609+ [
610+ {
611+ id: item.id,
612+ kind: "work",
613+ createdAt: MESSAGE_CREATED_AT,
614+ entry: {
615+ id: item.id,
616+ createdAt: MESSAGE_CREATED_AT,
617+ runId: null,
618+ label: "Ran command",
619+ command: item.input,
620+ tone: "tool",
621+ itemType: item.type,
622+ toolLifecycleStatus: "completed",
623+ structuredPayload: item,
624+ projectedItem,
625+ },
626+ },
627+ ] as never
628+ }
629+ />,
630+ );
631+
632+ expect(markup).toContain('data-v2-item-type="command_execution"');
633+ expect(markup).toContain('data-v2-item-visibility="inherited"');
634+ expect(markup).toContain("Inherited");
635+ });
636+
637+ it("formats changed file paths from the workspace root", async () => {
638+ const { MessagesTimeline } = await import("./MessagesTimeline");
531639 const markup = renderToStaticMarkup(
532640 <MessagesTimeline
533641 {...buildProps()}
@@ -541,6 +649,8 @@ describe("MessagesTimeline", () => { · 7 unmodified lines
541649 createdAt: "2026-03-17T19:12:28.000Z",
542650 label: "Updated files",
543651 tone: "tool",
652+ itemType: "file_change",
653+ toolLifecycleStatus: "completed",
544654 changedFiles: ["C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"],
545655 },
546656 },
@@ -575,7 +685,7 @@ describe("MessagesTimeline", () => { · 28 unmodified lines
575685 "```",
576686 "</review_comment>",
577687 ].join("\n"),
578− turnId: null,
688+ runId: null,
579689 createdAt: "2026-03-17T19:12:28.000Z",
580690 updatedAt: "2026-03-17T19:12:28.000Z",
581691 streaming: false,
@@ -614,7 +724,7 @@ describe("MessagesTimeline", () => { · 32 unmodified lines
614724 "```",
615725 "</review_comment>",
616726 ].join("\n"),
617− turnId: null,
727+ runId: null,
618728 createdAt: "2026-03-17T19:12:28.000Z",
619729 updatedAt: "2026-03-17T19:12:28.000Z",
620730 streaming: false,
apps/web/src/components/chat/MessagesTimeline.tsx+283−75
@@ -1,9 +1,10 @@
11 import {
22 type EnvironmentId,
33 type MessageId,
4+ type OrchestrationV2TurnItem,
45 type ScopedThreadRef,
56 type ServerProviderSkill,
6− type TurnId,
7+ type RunId,
78 } from "@t3tools/contracts";
89 import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
910 import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
@@ -78,8 +79,7 @@ import { · 68 unmodified lines
7879 resolveTimelineMinimapTopPercent,
7980 type StableMessagesTimelineRowsState,
8081 type MessagesTimelineRow,
81− TIMELINE_MINIMAP_MIN_ITEMS,
82− type TimelineLatestTurn,
82+ type TimelineLatestRun,
8383 } from "./MessagesTimeline.logic";
8484 import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
8585 import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
@@ -132,9 +132,8 @@ interface TimelineRowSharedState { · 46 unmodified lines
132132 activeThreadEnvironmentId: EnvironmentId;
133133 onRevertUserMessage: (messageId: MessageId) => void;
134134 onImageExpand: (preview: ExpandedImagePreview) => void;
135− onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
136− onToggleTurnFold: (turnId: TurnId) => void;
137− onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void;
135+ onOpenTurnDiff: (runId: RunId, filePath?: string) => void;
136+ onToggleTurnFold: (runId: RunId) => void;
138137 }
139138
140139 interface TimelineRowActivityState {
@@ -161,11 +160,10 @@ interface MessagesTimelineProps { · 20 unmodified lines
161160 activeTurnStartedAt: string | null;
162161 listRef: React.RefObject<LegendListRef | null>;
163162 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
164− latestTurn: TimelineLatestTurn | null;
165− runningTurnId: TurnId | null;
163+ latestRun: TimelineLatestRun | null;
166164 turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>;
167165 routeThreadKey: string;
168− onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
166+ onOpenTurnDiff: (runId: RunId, filePath?: string) => void;
169167 revertTurnCountByUserMessageId: Map<MessageId, number>;
170168 onRevertUserMessage: (messageId: MessageId) => void;
171169 isRevertingCheckpoint: boolean;
@@ -196,8 +194,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 24 unmodified lines
196194 activeTurnStartedAt,
197195 listRef,
198196 timelineEntries,
199− latestTurn,
200− runningTurnId,
197+ latestRun,
201198 turnDiffSummaryByAssistantMessageId,
202199 routeThreadKey,
203200 onOpenTurnDiff,
@@ -220,17 +217,23 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 16 unmodified lines
220217 hideEmptyPlaceholder = false,
221218 topFadeEnabled = false,
222219 }: MessagesTimelineProps) {
223− const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());
224− const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());
225− const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>());
226−
227− const onToggleTurnFold = useCallback((turnId: TurnId) => {
228− setExpandedTurnIds((existing) => {
220+ const [expandedRunIds, setExpandedRunIds] = useState<ReadonlySet<RunId>>(new Set());
221+
222+ // Toggling a fold inserts/removes rows between the fold row and the final
223+ // message — everything above the trigger is unchanged, so the trigger stays
224+ // put as long as the list doesn't re-anchor. maintainScrollAtEnd would do
225+ // exactly that (pin the bottom content when row data changes while scrolled
226+ // to the end), yanking the trigger out of view. Suppress it for the frames
227+ // in which the toggle's data change and item measurements settle.
228+ const [foldToggleSettling, setFoldToggleSettling] = useState(false);
229+ const onToggleTurnFold = useCallback((runId: RunId) => {
230+ setFoldToggleSettling(true);
231+ setExpandedRunIds((existing) => {
229232 const next = new Set(existing);
230− if (next.has(turnId)) {
231− next.delete(turnId);
233+ if (next.has(runId)) {
234+ next.delete(runId);
232235 } else {
233− next.add(turnId);
236+ next.add(runId);
234237 }
235238 return next;
236239 });
@@ -271,41 +274,39 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 34 unmodified lines
271274
272275 // An in-session interrupt leaves its turn expanded so the user keeps their
273276 // place; the next turn (or a reload, since this is local state) folds it.
274− const previousLatestTurnRef = useRef(latestTurn);
277+ const previousLatestRunRef = useRef(latestRun);
275278 useEffect(() => {
276− const previous = previousLatestTurnRef.current;
277− previousLatestTurnRef.current = latestTurn;
278− if (!latestTurn || previous?.turnId === undefined) {
279+ const previous = previousLatestRunRef.current;
280+ previousLatestRunRef.current = latestRun;
281+ if (!latestRun || previous?.runId === undefined) {
279282 return;
280283 }
281− if (latestTurn.turnId === previous.turnId) {
282− if (previous.state === "running" && latestTurn.state === "interrupted") {
283− setExpandedTurnIds((existing) => {
284+ if (latestRun.runId === previous.runId) {
285+ if (previous.status === "running" && latestRun.status === "interrupted") {
286+ setExpandedRunIds((existing) => {
284287 const next = new Set(existing);
285− next.add(latestTurn.turnId);
288+ next.add(latestRun.runId);
286289 return next;
287290 });
288291 }
289292 return;
290293 }
291− setExpandedTurnIds((existing) => {
292− if (!existing.has(previous.turnId)) {
294+ setExpandedRunIds((existing) => {
295+ if (!existing.has(previous.runId)) {
293296 return existing;
294297 }
295298 const next = new Set(existing);
296− next.delete(previous.turnId);
299+ next.delete(previous.runId);
297300 return next;
298301 });
299− }, [latestTurn]);
302+ }, [latestRun]);
300303
301304 const rawRows = useMemo(
302305 () =>
303306 deriveMessagesTimelineRows({
304307 timelineEntries,
305− latestTurn,
306− runningTurnId,
307− expandedTurnIds,
308− expandedWorkGroupIds,
308+ latestRun,
309+ expandedRunIds,
309310 isWorking,
310311 activeTurnStartedAt,
311312 turnDiffSummaryByAssistantMessageId,
@@ -313,10 +314,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 1 unmodified line
313314 }),
314315 [
315316 timelineEntries,
316− latestTurn,
317− runningTurnId,
318− expandedTurnIds,
319− expandedWorkGroupIds,
317+ latestRun,
318+ expandedRunIds,
320319 isWorking,
321320 activeTurnStartedAt,
322321 turnDiffSummaryByAssistantMessageId,
@@ -843,7 +842,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time · 520 unmodified lines
843842 // they sit closer to the work that follows them.
844843 (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) ||
845844 row.kind === "work" ||
846− row.kind === "work-toggle"
845+ row.kind === "event"
847846 ? "pb-2"
848847 : "pb-4",
849848 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,
@@ -861,6 +860,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time · 11 unmodified lines
861860 <AssistantTimelineRow row={row} />
862861 ) : null}
863862 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}
863+ {row.kind === "event" ? <V2EventTimelineRow row={row} /> : null}
864864 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
865865 </div>
866866 );
@@ -1005,7 +1005,7 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn- · 138 unmodified lines
10051005 type="button"
10061006 aria-expanded={row.expanded}
10071007 data-scroll-anchor-ignore
1008− onClick={() => ctx.onToggleTurnFold(row.turnId)}
1008+ onClick={() => ctx.onToggleTurnFold(row.runId)}
10091009 className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-xs text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
10101010 >
10111011 <span>{row.label}</span>
@@ -1091,6 +1091,173 @@ function ProposedPlanTimelineRow({ · 79 unmodified lines
10911091 );
10921092 }
10931093
1094+type V2EventTone = "muted" | "warning" | "danger" | "success";
1095+
1096+function v2EventPresentation(item: OrchestrationV2TurnItem): {
1097+ readonly label: string;
1098+ readonly detail: string | null;
1099+ readonly tone: V2EventTone;
1100+ readonly icon: typeof CircleAlertIcon;
1101+} {
1102+ switch (item.type) {
1103+ case "run_interrupt_request":
1104+ return {
1105+ label: "Interrupt requested",
1106+ detail: item.message,
1107+ tone: "warning",
1108+ icon: CircleAlertIcon,
1109+ };
1110+ case "run_interrupt_result":
1111+ return {
1112+ label: "Run interrupted",
1113+ detail: item.message,
1114+ tone: "danger",
1115+ icon: XIcon,
1116+ };
1117+ case "handoff":
1118+ return {
1119+ label: "Context handoff",
1120+ detail:
1121+ item.summary ??
1122+ `${item.fromProviderInstanceIds.join(", ")} → ${item.toProviderInstanceId}`,
1123+ tone: item.status === "failed" ? "danger" : "muted",
1124+ icon: ZapIcon,
1125+ };
1126+ case "fork":
1127+ return {
1128+ label: "Conversation fork",
1129+ detail: `Continues in ${item.targetThreadId}`,
1130+ tone: "muted",
1131+ icon: HammerIcon,
1132+ };
1133+ case "compaction": {
1134+ const tokenSummary =
1135+ item.beforeTokenCount === undefined && item.afterTokenCount === undefined
1136+ ? null
1137+ : `${item.beforeTokenCount ?? "?"} → ${item.afterTokenCount ?? "?"} tokens`;
1138+ return {
1139+ label: "Context compacted",
1140+ detail: item.summary ?? tokenSummary,
1141+ tone: item.status === "failed" ? "danger" : "muted",
1142+ icon: MinusIcon,
1143+ };
1144+ }
1145+ case "subagent":
1146+ return {
1147+ label: item.title ?? "Subagent",
1148+ detail: item.result ?? item.prompt,
1149+ tone:
1150+ item.status === "failed" ? "danger" : item.status === "completed" ? "success" : "muted",
1151+ icon: HammerIcon,
1152+ };
1153+ case "approval_request":
1154+ return {
1155+ label: "Approval requested",
1156+ detail: item.prompt ?? item.requestKind,
1157+ tone: item.status === "failed" ? "danger" : "warning",
1158+ icon: MessageCircleIcon,
1159+ };
1160+ case "user_input_request":
1161+ return {
1162+ label: "Input requested",
1163+ detail: item.questions.map((question) => question.question).join("\n"),
1164+ tone: item.status === "failed" ? "danger" : "warning",
1165+ icon: MessageCircleIcon,
1166+ };
1167+ case "todo_list": {
1168+ const steps = item.steps.map((step) => `${step.status}: ${step.text}`).join("\n");
1169+ return {
1170+ label: "Plan updated",
1171+ detail: [item.explanation, steps].filter(Boolean).join("\n\n") || null,
1172+ tone: item.status === "failed" ? "danger" : "success",
1173+ icon: CheckIcon,
1174+ };
1175+ }
1176+ default:
1177+ return {
1178+ label: item.title?.trim() || item.type.replaceAll("_", " "),
1179+ detail: null,
1180+ tone: item.status === "failed" ? "danger" : "muted",
1181+ icon: WrenchIcon,
1182+ };
1183+ }
1184+}
1185+
1186+function V2EventTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "event" }> }) {
1187+ const ctx = use(TimelineRowCtx);
1188+ const { item, visibility, sourceThreadId } = row.projectedItem;
1189+ const presentation = v2EventPresentation(item);
1190+ const Icon = presentation.icon;
1191+ return (
1192+ <section
1193+ className={cn(
1194+ "rounded-lg border px-3 py-2",
1195+ presentation.tone === "warning" && "border-amber-500/25 bg-amber-500/5",
1196+ presentation.tone === "danger" && "border-destructive/25 bg-destructive/5",
1197+ presentation.tone === "success" && "border-emerald-500/20 bg-emerald-500/5",
1198+ presentation.tone === "muted" && "border-border/60 bg-card/30",
1199+ )}
1200+ data-v2-item-type={item.type}
1201+ data-v2-item-visibility={visibility}
1202+ >
1203+ <div className="flex items-start gap-2.5">
1204+ <Icon
1205+ className={cn(
1206+ "mt-0.5 size-3.5 shrink-0",
1207+ presentation.tone === "warning" && "text-amber-600 dark:text-amber-400",
1208+ presentation.tone === "danger" && "text-destructive",
1209+ presentation.tone === "success" && "text-emerald-600 dark:text-emerald-400",
1210+ presentation.tone === "muted" && "text-muted-foreground",
1211+ )}
1212+ />
1213+ <div className="min-w-0 flex-1">
1214+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
1215+ <span className="text-xs font-medium text-foreground/90">{presentation.label}</span>
1216+ {item.status !== "completed" ? (
1217+ <span className="rounded-full border border-border/70 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
1218+ {item.status}
1219+ </span>
1220+ ) : null}
1221+ {visibility !== "local" ? (
1222+ <span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
1223+ {visibility === "inherited" ? "Inherited" : "Synthetic"}
1224+ </span>
1225+ ) : null}
1226+ </div>
1227+ {presentation.detail ? (
1228+ <div className="mt-1 text-xs leading-relaxed text-muted-foreground">
1229+ <ChatMarkdown
1230+ text={presentation.detail}
1231+ cwd={ctx.markdownCwd}
1232+ threadRef={ctx.threadRef ?? undefined}
1233+ skills={ctx.skills}
1234+ lineBreaks
1235+ />
1236+ </div>
1237+ ) : null}
1238+ {visibility === "inherited" ? (
1239+ <p className="mt-1 font-mono text-[10px] text-muted-foreground/65">
1240+ From {sourceThreadId}
1241+ </p>
1242+ ) : null}
1243+ <details
1244+ className="group/v2-details mt-2 rounded-md border border-border/50 bg-background/45"
1245+ data-v2-structured-details="true"
1246+ >
1247+ <summary className="flex cursor-pointer list-none items-center gap-1.5 px-2 py-1.5 text-[11px] font-medium text-muted-foreground select-none hover:text-foreground [&::-webkit-details-marker]:hidden">
1248+ <ChevronRightIcon className="size-3 transition-transform group-open/v2-details:rotate-90" />
1249+ Structured details
1250+ </summary>
1251+ <pre className="max-h-80 overflow-auto border-t border-border/50 px-2 py-2 font-mono text-[10px] leading-relaxed whitespace-pre-wrap text-muted-foreground">
1252+ {JSON.stringify(item, null, 2)}
1253+ </pre>
1254+ </details>
1255+ </div>
1256+ </div>
1257+ </section>
1258+ );
1259+}
1260+
10941261 function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
10951262 return (
10961263 <div className="py-0.5 pl-1.5">
@@ -1244,7 +1411,7 @@ const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection( · 147 unmodified lines
12441411 turnSummary: TurnDiffSummary | undefined;
12451412 routeThreadKey: string;
12461413 resolvedTheme: "light" | "dark";
1247− onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
1414+ onOpenTurnDiff: (runId: RunId, filePath?: string) => void;
12481415 }) {
12491416 if (!turnSummary) return null;
12501417 const checkpointFiles = turnSummary.files;
@@ -1274,12 +1441,10 @@ function AssistantChangedFilesSectionInner({ · 23 unmodified lines
12741441 checkpointFiles: TurnDiffSummary["files"];
12751442 routeThreadKey: string;
12761443 resolvedTheme: "light" | "dark";
1277− onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
1444+ onOpenTurnDiff: (runId: RunId, filePath?: string) => void;
12781445 }) {
1279− const activity = use(TimelineRowActivityCtx);
1280− const isLatestTurn = activity.latestTurnId === turnSummary.turnId;
1281− const persistedExpanded = useUiStateStore(
1282− (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId],
1446+ const allDirectoriesExpanded = useUiStateStore(
1447+ (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.runId] ?? true,
12831448 );
12841449 const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded);
12851450 const [autoExpanded] = useState(() =>
@@ -1289,19 +1454,46 @@ function AssistantChangedFilesSectionInner({ · 3 unmodified lines
12891454 const expanded = persistedExpanded ?? (isLatestTurn && autoExpanded);
12901455
12911456 return (
1292− <ChangedFilesCard
1293− turnId={turnSummary.turnId}
1294− files={checkpointFiles}
1295− expanded={expanded}
1296− showCompactPreview={isLatestTurn}
1297− allDirectoriesExpanded={allDirectoriesExpanded}
1298− resolvedTheme={resolvedTheme}
1299− onExpandedChange={(nextExpanded) =>
1300− setExpanded(routeThreadKey, turnSummary.turnId, nextExpanded)
1301− }
1302− onToggleAllDirectories={() => setAllDirectoriesExpanded((current) => !current)}
1303− onOpenTurnDiff={onOpenTurnDiff}
1304− />
1457+ <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
1458+ <div className="sticky top-2 z-10 mb-1.5 flex items-center justify-between gap-2 bg-[color-mix(in_srgb,var(--card)_45%,var(--background))] before:absolute before:inset-x-0 before:-top-2 before:h-2 before:bg-[color-mix(in_srgb,var(--card)_45%,var(--background))] before:content-['']">
1459+ <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
1460+ <span>Changed files ({changedFileCountLabel})</span>
1461+ {hasNonZeroStat(summaryStat) && (
1462+ <>
1463+ <span className="mx-1">•</span>
1464+ <DiffStatLabel additions={summaryStat.additions} deletions={summaryStat.deletions} />
1465+ </>
1466+ )}
1467+ </p>
1468+ <div className="flex items-center gap-1.5">
1469+ <Button
1470+ type="button"
1471+ size="xs"
1472+ variant="outline"
1473+ data-scroll-anchor-ignore
1474+ onClick={() => setExpanded(routeThreadKey, turnSummary.runId, !allDirectoriesExpanded)}
1475+ >
1476+ {allDirectoriesExpanded ? "Collapse all" : "Expand all"}
1477+ </Button>
1478+ <Button
1479+ type="button"
1480+ size="xs"
1481+ variant="outline"
1482+ onClick={() => onOpenTurnDiff(turnSummary.runId, checkpointFiles[0]?.path)}
1483+ >
1484+ View diff
1485+ </Button>
1486+ </div>
1487+ </div>
1488+ <ChangedFilesTree
1489+ key={`changed-files-tree:${turnSummary.runId}`}
1490+ runId={turnSummary.runId}
1491+ files={checkpointFiles}
1492+ allDirectoriesExpanded={allDirectoriesExpanded}
1493+ resolvedTheme={resolvedTheme}
1494+ onOpenTurnDiff={onOpenTurnDiff}
1495+ />
1496+ </div>
13051497 );
13061498 }
13071499
@@ -1850,8 +2042,8 @@ function buildToolCallExpandedBody( · 542 unmodified lines
18502042 workspaceRoot: string | undefined,
18512043 ): string | null {
18522044 const blocks: string[] = [];
1853− if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) {
1854− blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`);
2045+ if (workEntry.itemType === "dynamic_tool" && workEntry.toolData !== undefined) {
2046+ blocks.push(`Tool call\n${JSON.stringify(workEntry.toolData, null, 2)}`);
18552047 }
18562048 const raw = workEntryRawCommand(workEntry);
18572049 if (raw?.trim()) {
@@ -1870,14 +2062,25 @@ function buildToolCallExpandedBody( · 12 unmodified lines
18702062 .join("\n"),
18712063 );
18722064 }
2065+ if (workEntry.structuredPayload !== undefined) {
2066+ const structured = JSON.stringify(workEntry.structuredPayload, null, 2);
2067+ if (structured && !blocks.includes(structured)) {
2068+ blocks.push(structured);
2069+ }
2070+ }
2071+ if (workEntry.projectedItem?.visibility !== undefined) {
2072+ const { visibility, sourceThreadId } = workEntry.projectedItem;
2073+ if (visibility !== "local") {
2074+ blocks.push(
2075+ `${visibility === "inherited" ? "Inherited" : "Synthetic"} from ${sourceThreadId}`,
2076+ );
2077+ }
2078+ }
18732079 return blocks.length > 0 ? blocks.join("\n\n") : null;
18742080 }
18752081
18762082 function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {
1877− if (
1878− workEntry.sourceActivityKind === "user-input.requested" ||
1879− workEntry.sourceActivityKind === "user-input.resolved"
1880− ) {
2083+ if (workEntry.itemType === "user_input_request") {
18812084 return "message-circle";
18822085 }
18832086 if (workEntry.requestKind === "command") return "terminal";
@@ -1891,13 +2094,12 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { · 7 unmodified lines
18912094 return "square-pen";
18922095 }
18932096 if (workEntry.itemType === "web_search") return "globe";
1894− if (workEntry.itemType === "image_view") return "eye";
2097+ if (workEntry.itemType === "file_search") return "eye";
18952098
18962099 switch (workEntry.itemType) {
1897− case "mcp_tool_call":
2100+ case "dynamic_tool":
18982101 return "wrench";
1899− case "dynamic_tool_call":
1900− case "collab_agent_tool_call":
2102+ case "subagent":
19012103 return "hammer";
19022104 }
19032105
@@ -1929,7 +2131,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 25 unmodified lines
19292131 const activity = use(TimelineRowActivityCtx);
19302132 const [expanded, setExpanded] = useState(false);
19312133 const iconConfig = workToneIcon(workEntry.tone);
1932− const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning";
2134+ const showWarningIndicator = false;
19332135 const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry);
19342136 const heading = toolWorkEntryHeading(workEntry);
19352137 const rawPreview = workEntryPreview(workEntry, workspaceRoot);
@@ -1943,9 +2145,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 7 unmodified lines
19432145 const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot);
19442146 const canExpand = expandedBody !== null;
19452147 const showFailedIndicator = workEntryIndicatesToolFailure(workEntry);
1946− const showDestructiveRowStyle =
1947− showFailedIndicator &&
1948− (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry));
2148+ const showDestructiveRowStyle = showFailedIndicator && !workLogEntryIsToolLike(workEntry);
19492149 const iconWrapperClass = cn(
19502150 "flex size-5 shrink-0 items-center justify-center",
19512151 showWarningIndicator
@@ -1988,6 +2188,8 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 36 unmodified lines
19882188 canExpand &&
19892189 "cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70",
19902190 )}
2191+ data-v2-item-type={workEntry.projectedItem?.item.type}
2192+ data-v2-item-visibility={workEntry.projectedItem?.visibility}
19912193 {...rowToggleProps}
19922194 >
19932195 <div className="flex select-none items-center gap-1.5 transition-[opacity,translate] duration-200">
@@ -2001,6 +2203,12 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 7 unmodified lines
20012203 <div className="min-w-0 flex-1 overflow-hidden">
20022204 <p className="flex min-w-0 w-full items-baseline gap-1.5 text-[12px] leading-5">
20032205 <span className={cn("min-w-0 shrink truncate", headingClass)}>{heading}</span>
2206+ {workEntry.projectedItem?.visibility !== undefined &&
2207+ workEntry.projectedItem.visibility !== "local" ? (
2208+ <span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[9px] leading-none text-muted-foreground">
2209+ {workEntry.projectedItem.visibility === "inherited" ? "Inherited" : "Synthetic"}
2210+ </span>
2211+ ) : null}
20042212 {preview && (
20052213 <span className="min-w-0 flex-1 truncate text-muted-foreground/55">{preview}</span>
20062214 )}
apps/web/src/components/settings/SettingsPanels.tsx+2−4
@@ -24,6 +24,7 @@ import { · 23 unmodified lines
2424 type SidebarProjectGroupingMode,
2525 } from "@t3tools/contracts";
2626 import { scopeThreadRef } from "@t3tools/client-runtime/environment";
27+import { presentThreadShell } from "@t3tools/client-runtime/state/shell";
2728 import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
2829 import {
2930 isAtomCommandInterrupted,
@@ -2245,10 +2246,7 @@ export function ArchivedThreadsPanel() { · 2215 unmodified lines
22452246 ),
22462247 );
22472248 const threads = archivedSnapshots.flatMap(({ environmentId, snapshot }) =>
2248− snapshot.threads.map((thread) => ({
2249− ...thread,
2250− environmentId,
2251− })),
2249+ snapshot.threads.map((thread) => presentThreadShell(environmentId, thread)),
22522250 );
22532251
22542252 const archivedProjects = Array.from(projectsByEnvironmentAndId.values());
apps/web/src/connection/storage.ts+36−51
@@ -6,6 +6,10 @@ import { · 5 unmodified lines
66 ConnectionTargetStore,
77 EMPTY_CONNECTION_CATALOG_DOCUMENT,
88 EnvironmentCacheStore,
9+ ORCHESTRATION_CACHE_SCHEMA_VERSION,
10+ StoredOrchestrationShellSnapshot,
11+ StoredOrchestrationThreadSnapshot,
12+ decodeOrDiscardOrchestrationCache,
913 registerConnectionInCatalog,
1014 removeCatalogValue,
1115 removeConnectionFromCatalog,
@@ -17,14 +21,7 @@ import { · 5 unmodified lines
1721 CredentialStore,
1822 ProfileStore,
1923 } from "@t3tools/client-runtime/connection";
20−import {
21− EnvironmentId,
22− OrchestrationShellSnapshot,
23− OrchestrationThreadDetailSnapshot,
24− ServerConfig,
25− ThreadId,
26− VcsListRefsResult,
27−} from "@t3tools/contracts";
24+import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2825 import * as Context from "effect/Context";
2926 import * as Effect from "effect/Effect";
3027 import * as Layer from "effect/Layer";
@@ -41,23 +38,9 @@ const THREAD_STORE_NAME = "thread"; · 10 unmodified lines
4138 const SERVER_CONFIG_STORE_NAME = "server-config";
4239 const VCS_REFS_STORE_NAME = "vcs-refs";
4340 const CATALOG_KEY = "document";
44−const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
45−
46−const StoredShellSnapshot = Schema.Struct({
47− schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION),
48− environmentId: EnvironmentId,
49− snapshot: OrchestrationShellSnapshot,
50−});
41+const StoredShellSnapshot = StoredOrchestrationShellSnapshot;
5142 const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot);
52−// v2 stores the snapshot sequence alongside the thread so a warm cache can
53−// resume via `afterSequence` instead of re-downloading the full thread body.
54−// Older v1 entries (no sequence) fail to decode and are treated as a cold cache.
55−const StoredThreadSnapshot = Schema.Struct({
56− schemaVersion: Schema.Literal(2),
57− environmentId: EnvironmentId,
58− threadId: ThreadId,
59− snapshot: OrchestrationThreadDetailSnapshot,
60−});
43+const StoredThreadSnapshot = StoredOrchestrationThreadSnapshot;
6144 const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot);
6245 const StoredServerConfig = Schema.Struct({
6346 schemaVersion: Schema.Literal(1),
@@ -469,25 +452,24 @@ export const connectionStorageLayer = Layer.effectContext( · 405 unmodified lines
469452 if (typeof raw !== "string") {
470453 return Effect.succeed(Option.none());
471454 }
472− return decodeStoredShellSnapshot(raw).pipe(
473− Effect.mapError((cause) => persistenceError("load-shell", cause)),
474− Effect.map((stored) =>
475− stored.environmentId === environmentId
476− ? Option.some(stored.snapshot)
477− : Option.none(),
455+ return decodeOrDiscardOrchestrationCache(
456+ decodeStoredShellSnapshot(raw).pipe(
457+ Effect.mapError((cause) => persistenceError("load-shell", cause)),
458+ Effect.map((stored) =>
459+ stored.environmentId === environmentId
460+ ? Option.some(stored.snapshot)
461+ : Option.none(),
462+ ),
478463 ),
464+ removeDatabaseValue(database, SHELL_STORE_NAME, environmentId),
479465 );
480466 }),
481− Effect.mapError((cause) =>
482− cause._tag === "ConnectionPersistenceError"
483− ? cause
484− : persistenceError("load-shell", cause),
485− ),
467+ Effect.mapError((cause) => persistenceError("load-shell", cause)),
486468 ),
487469 saveShell: (environmentId, snapshot) =>
488470 Effect.gen(function* () {
489471 const encoded = yield* encodeStoredShellSnapshot({
490− schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION,
472+ schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
491473 environmentId,
492474 snapshot,
493475 }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause)));
@@ -543,33 +525,36 @@ export const connectionStorageLayer = Layer.effectContext( · 49 unmodified lines
543525 if (typeof raw !== "string") {
544526 return Effect.succeed(Option.none());
545527 }
546− return decodeStoredThreadSnapshot(raw).pipe(
547− Effect.mapError((cause) => persistenceError("load-thread", cause)),
548− Effect.map((stored) =>
549− stored.environmentId === environmentId && stored.threadId === threadId
550− ? Option.some(stored.snapshot)
551− : Option.none(),
528+ return decodeOrDiscardOrchestrationCache(
529+ decodeStoredThreadSnapshot(raw).pipe(
530+ Effect.mapError((cause) => persistenceError("load-thread", cause)),
531+ Effect.map((stored) =>
532+ stored.environmentId === environmentId && stored.threadId === threadId
533+ ? Option.some(stored.thread)
534+ : Option.none(),
535+ ),
536+ ),
537+ removeDatabaseValue(
538+ database,
539+ THREAD_STORE_NAME,
540+ threadCacheKey(environmentId, threadId),
552541 ),
553542 );
554543 }),
555− Effect.mapError((cause) =>
556− cause._tag === "ConnectionPersistenceError"
557− ? cause
558− : persistenceError("load-thread", cause),
559− ),
544+ Effect.mapError((cause) => persistenceError("load-thread", cause)),
560545 ),
561546 saveThread: (environmentId, snapshot) =>
562547 Effect.gen(function* () {
563548 const encoded = yield* encodeStoredThreadSnapshot({
564− schemaVersion: 2,
549+ schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
565550 environmentId,
566− threadId: snapshot.thread.id,
567− snapshot,
551+ threadId: thread.thread.id,
552+ thread,
568553 }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause)));
569554 yield* writeDatabaseValue(
570555 database,
571556 THREAD_STORE_NAME,
572− threadCacheKey(environmentId, snapshot.thread.id),
557+ threadCacheKey(environmentId, thread.thread.id),
573558 encoded,
574559 );
575560 }).pipe(
apps/web/src/diffPanelStore.test.ts+5−5
@@ -1,5 +1,5 @@
11 import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2−import { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts";
2+import { EnvironmentId, ThreadId, RunId } from "@t3tools/contracts";
33 import { beforeEach, describe, expect, it } from "vite-plus/test";
44
55 import { selectThreadDiffPanelSelection, useDiffPanelStore } from "./diffPanelStore";
@@ -31,7 +31,7 @@ describe("diffPanelStore", () => { · 25 unmodified lines
3131
3232 it("clears incompatible selection fields when changing scopes", () => {
3333 const store = useDiffPanelStore.getState();
34− store.selectTurn(THREAD_REF, TurnId.make("turn-1"), "src/app.ts");
34+ store.selectTurn(THREAD_REF, RunId.make("turn-1"), "src/app.ts");
3535 store.selectGitScope(THREAD_REF, "unstaged");
3636
3737 expect(
@@ -45,7 +45,7 @@ describe("diffPanelStore", () => { · 7 unmodified lines
4545 });
4646
4747 it("increments the reveal request when opening the same turn file again", () => {
48− const turnId = TurnId.make("turn-1");
48+ const turnId = RunId.make("turn-1");
4949 useDiffPanelStore.getState().selectTurn(THREAD_REF, turnId, "src/app.ts");
5050 useDiffPanelStore.getState().selectTurn(THREAD_REF, turnId, "src/app.ts");
5151
@@ -65,8 +65,8 @@ describe("diffPanelStore", () => { · 13 unmodified lines
6565 });
6666
6767 it("reconciles a missing turn selection to the latest available turn", () => {
68− const missingTurnId = TurnId.make("turn-missing");
69− const latestTurnId = TurnId.make("turn-latest");
68+ const missingTurnId = RunId.make("turn-missing");
69+ const latestTurnId = RunId.make("turn-latest");
7070 useDiffPanelStore.getState().selectTurn(THREAD_REF, missingTurnId, "src/app.ts");
7171 useDiffPanelStore.getState().reconcileTurnSelection(THREAD_REF, [latestTurnId]);
7272
apps/web/src/diffPanelStore.ts+5−5
@@ -1,5 +1,5 @@
11 import { scopedThreadKey } from "@t3tools/client-runtime/environment";
2−import type { ScopedThreadRef, TurnId } from "@t3tools/contracts";
2+import type { RunId, ScopedThreadRef } from "@t3tools/contracts";
33 import { create } from "zustand";
44 import { createJSONStorage, persist } from "zustand/middleware";
55
@@ -8,7 +8,7 @@ import { resolveStorage } from "./lib/storage"; · 2 unmodified lines
88 export type DiffPanelSelection =
99 | { kind: "branch"; baseRef: string | null }
1010 | { kind: "unstaged" }
11− | { kind: "turn"; turnId: TurnId; filePath: string | null; revealRequestId: number };
11+ | { kind: "turn"; turnId: RunId; filePath: string | null; revealRequestId: number };
1212
1313 const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null };
1414 const DEFAULT_WORKING_TREE_SELECTION: DiffPanelSelection = { kind: "unstaged" };
@@ -18,8 +18,8 @@ interface DiffPanelStoreState { · 3 unmodified lines
1818 branchBaseRefByThreadKey: Record<string, string | null>;
1919 selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void;
2020 selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void;
21− selectTurn: (ref: ScopedThreadRef, turnId: TurnId, filePath?: string) => void;
22− reconcileTurnSelection: (ref: ScopedThreadRef, availableTurnIds: ReadonlyArray<TurnId>) => void;
21+ selectTurn: (ref: ScopedThreadRef, turnId: RunId, filePath?: string) => void;
22+ reconcileTurnSelection: (ref: ScopedThreadRef, availableTurnIds: ReadonlyArray<RunId>) => void;
2323 removeThread: (ref: ScopedThreadRef) => void;
2424 }
2525
@@ -119,7 +119,7 @@ export const useDiffPanelStore = create<DiffPanelStoreState>()( · 93 unmodified lines
119119 }),
120120 {
121121 name: "t3code:diff-panel-state:v1",
122− version: 1,
122+ version: 2,
123123 storage: createJSONStorage(() =>
124124 resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined),
125125 ),
apps/web/src/historyBootstrap.test.ts+7−7
@@ -14,7 +14,7 @@ describe("buildBootstrapInput", () => { · 13 unmodified lines
1414 role: "user",
1515 text: "hello",
1616 createdAt: "2026-02-09T00:00:00.000Z",
17− turnId: null,
17+ runId: null,
1818 updatedAt: "2026-02-09T00:00:00.000Z",
1919 streaming: false,
2020 },
@@ -23,7 +23,7 @@ describe("buildBootstrapInput", () => { · 2 unmodified lines
2323 role: "assistant",
2424 text: "world",
2525 createdAt: "2026-02-09T00:00:01.000Z",
26− turnId: null,
26+ runId: null,
2727 updatedAt: "2026-02-09T00:00:01.000Z",
2828 streaming: false,
2929 },
@@ -49,7 +49,7 @@ describe("buildBootstrapInput", () => { · 19 unmodified lines
4949 role: "user",
5050 text: "first question with details",
5151 createdAt: "2026-02-09T00:00:00.000Z",
52− turnId: null,
52+ runId: null,
5353 updatedAt: "2026-02-09T00:00:00.000Z",
5454 streaming: false,
5555 },
@@ -58,7 +58,7 @@ describe("buildBootstrapInput", () => { · 2 unmodified lines
5858 role: "assistant",
5959 text: "first answer with details",
6060 createdAt: "2026-02-09T00:00:01.000Z",
61− turnId: null,
61+ runId: null,
6262 updatedAt: "2026-02-09T00:00:01.000Z",
6363 streaming: false,
6464 },
@@ -67,7 +67,7 @@ describe("buildBootstrapInput", () => { · 2 unmodified lines
6767 role: "user",
6868 text: "second question with details",
6969 createdAt: "2026-02-09T00:00:02.000Z",
70− turnId: null,
70+ runId: null,
7171 updatedAt: "2026-02-09T00:00:02.000Z",
7272 streaming: false,
7373 },
@@ -92,7 +92,7 @@ describe("buildBootstrapInput", () => { · 18 unmodified lines
9292 role: "user",
9393 text: "old context",
9494 createdAt: "2026-02-09T00:00:00.000Z",
95− turnId: null,
95+ runId: null,
9696 updatedAt: "2026-02-09T00:00:00.000Z",
9797 streaming: false,
9898 },
@@ -124,7 +124,7 @@ describe("buildBootstrapInput", () => { · 25 unmodified lines
124124 },
125125 ],
126126 createdAt: "2026-02-09T00:00:00.000Z",
127− turnId: null,
127+ runId: null,
128128 updatedAt: "2026-02-09T00:00:00.000Z",
129129 streaming: false,
130130 },
apps/web/src/hooks/useThreadActions.ts+2−2
@@ -160,7 +160,7 @@ export function useThreadActions() { · 159 unmodified lines
160160 const resolved = resolveThreadTarget(target);
161161 if (!resolved) return AsyncResult.success(undefined);
162162 const { thread, threadRef } = resolved;
163− if (thread.session?.status === "running" && thread.session.activeTurnId != null) {
163+ if (thread.runtime?.status === "running" && thread.runtime.activeRunId != null) {
164164 return AsyncResult.failure(
165165 Cause.fail(
166166 new ThreadArchiveBlockedError({
@@ -277,7 +277,7 @@ export function useThreadActions() { · 110 unmodified lines
277277 shouldDeleteWorktree = confirmationResult.value;
278278 }
279279
280− if (thread.session && thread.session.status !== "stopped") {
280+ if (thread.runtime !== null) {
281281 await stopThreadSession({
282282 environmentId: threadRef.environmentId,
283283 input: { threadId: threadRef.threadId },
apps/web/src/hooks/useTurnDiffSummaries.ts+4−4
@@ -1,5 +1,5 @@
11 import { useMemo } from "react";
2−import { inferCheckpointTurnCountByTurnId } from "../session-logic";
2+import { inferCheckpointTurnCountByRunId } from "../session-logic";
33 import type { Thread, TurnDiffSummary } from "../types";
44
55 export function useTurnDiffSummaries(activeThread: Thread | null | undefined) {
@@ -10,10 +10,10 @@ export function useTurnDiffSummaries(activeThread: Thread | null | undefined) { · 4 unmodified lines
1010 return activeThread.checkpoints;
1111 }, [activeThread]);
1212
13− const inferredCheckpointTurnCountByTurnId = useMemo(
14− () => inferCheckpointTurnCountByTurnId(turnDiffSummaries),
13+ const inferredCheckpointTurnCountByRunId = useMemo(
14+ () => inferCheckpointTurnCountByRunId(turnDiffSummaries),
1515 [turnDiffSummaries],
1616 );
1717
18− return { turnDiffSummaries, inferredCheckpointTurnCountByTurnId };
18+ return { turnDiffSummaries, inferredCheckpointTurnCountByRunId };
1919 }
apps/web/src/lib/contextWindow.test.ts+35−75
@@ -1,84 +1,44 @@
11 import { describe, expect, it } from "vite-plus/test";
2−import { EventId, type OrchestrationThreadActivity, TurnId } from "@t3tools/contracts";
3−
42 import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "./contextWindow";
53
6−function makeActivity(id: string, kind: string, payload: unknown): OrchestrationThreadActivity {
7− return {
8− id: EventId.make(id),
9− tone: "info",
10− kind,
11− summary: kind,
12− payload,
13− turnId: TurnId.make("turn-1"),
14− createdAt: "2026-03-23T00:00:00.000Z",
15− };
16−}
17−
18−describe("contextWindow", () => {
19− it("derives the latest valid context window snapshot", () => {
20− const snapshot = deriveLatestContextWindowSnapshot([
21− makeActivity("activity-1", "context-window.updated", {
22− usedTokens: 1000,
23− }),
24− makeActivity("activity-2", "tool.started", {}),
25− makeActivity("activity-3", "context-window.updated", {
26− usedTokens: 14_000,
27− maxTokens: 258_000,
28− compactsAutomatically: true,
29− }),
30− ]);
31−
32− expect(snapshot).not.toBeNull();
33− expect(snapshot?.usedTokens).toBe(14_000);
34− expect(snapshot?.totalProcessedTokens).toBeNull();
35− expect(snapshot?.maxTokens).toBe(258_000);
36− expect(snapshot?.compactsAutomatically).toBe(true);
37− });
38−
39− it("ignores malformed payloads", () => {
40− const snapshot = deriveLatestContextWindowSnapshot([
41− makeActivity("activity-1", "context-window.updated", {}),
42− ]);
43−
44− expect(snapshot).toBeNull();
45− });
46−
47− it("keeps valid zero-usage snapshots", () => {
4+describe("V2 context window presentation", () => {
5+ it("uses retained compaction token data when available", () => {
486 const snapshot = deriveLatestContextWindowSnapshot([
49− makeActivity("activity-1", "context-window.updated", {
50− usedTokens: 0,
51− maxTokens: 100_000,
52− }),
7+ {
8+ id: "compaction-1",
9+ createdAt: "2026-06-20T00:00:00.000Z",
10+ runId: null,
11+ label: "Context compacted",
12+ tone: "info",
13+ itemType: "compaction",
14+ toolLifecycleStatus: "completed",
15+ structuredPayload: {
16+ id: "compaction-1" as never,
17+ threadId: "thread-1" as never,
18+ runId: null,
19+ nodeId: null,
20+ providerThreadId: null,
21+ providerTurnId: null,
22+ nativeItemRef: null,
23+ parentItemId: null,
24+ ordinal: 1,
25+ status: "completed",
26+ title: null,
27+ startedAt: null,
28+ completedAt: null,
29+ updatedAt: {} as never,
30+ type: "compaction",
31+ driver: null,
32+ beforeTokenCount: 10_000,
33+ afterTokenCount: 2_000,
34+ },
35+ },
5336 ]);
54−
55− expect(snapshot).toMatchObject({
56− usedTokens: 0,
57− maxTokens: 100_000,
58− remainingTokens: 100_000,
59− usedPercentage: 0,
60− remainingPercentage: 100,
61− });
37+ expect(snapshot?.usedTokens).toBe(2_000);
38+ expect(snapshot?.totalProcessedTokens).toBe(10_000);
6239 });
6340
64− it("formats compact token counts", () => {
65− expect(formatContextWindowTokens(999)).toBe("999");
66− expect(formatContextWindowTokens(1400)).toBe("1.4k");
67− expect(formatContextWindowTokens(14_000)).toBe("14k");
68− expect(formatContextWindowTokens(258_000)).toBe("258k");
69− });
70−
71− it("includes total processed tokens when available", () => {
72− const snapshot = deriveLatestContextWindowSnapshot([
73− makeActivity("activity-1", "context-window.updated", {
74− usedTokens: 81_659,
75− totalProcessedTokens: 748_126,
76− maxTokens: 258_400,
77− lastUsedTokens: 81_659,
78− }),
79− ]);
80−
81− expect(snapshot?.usedTokens).toBe(81_659);
82− expect(snapshot?.totalProcessedTokens).toBe(748_126);
41+ it("formats compact token values", () => {
42+ expect(formatContextWindowTokens(1_500)).toBe("1.5k");
8343 });
8444 });
apps/web/src/lib/contextWindow.ts+23−31
@@ -1,17 +1,10 @@
1−import type { OrchestrationThreadActivity, ThreadTokenUsageSnapshot } from "@t3tools/contracts";
2−
3−function asRecord(value: unknown): Record<string, unknown> | null {
4− return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
5−}
1+import type { ThreadTokenUsageSnapshot } from "@t3tools/contracts";
2+import type { ThreadWorkEntry } from "@t3tools/client-runtime/state/shell";
63
74 function asFiniteNumber(value: unknown): number | null {
85 return typeof value === "number" && Number.isFinite(value) ? value : null;
96 }
107
11−function asBoolean(value: unknown): boolean | null {
12− return typeof value === "boolean" ? value : null;
13−}
14−
158 type NullableContextWindowUsage = {
169 readonly [Key in keyof ThreadTokenUsageSnapshot]: undefined extends ThreadTokenUsageSnapshot[Key]
1710 ? Exclude<ThreadTokenUsageSnapshot[Key], undefined> | null
@@ -48,21 +41,20 @@ export function formatProviderDisplayName(provider: string | null | undefined): · 30 unmodified lines
4841 }
4942
5043 export function deriveLatestContextWindowSnapshot(
51− activities: ReadonlyArray<OrchestrationThreadActivity>,
44+ entries: ReadonlyArray<ThreadWorkEntry>,
5245 ): ContextWindowSnapshot | null {
53− for (let index = activities.length - 1; index >= 0; index -= 1) {
54− const activity = activities[index];
55− if (!activity || activity.kind !== "context-window.updated") {
46+ for (let index = entries.length - 1; index >= 0; index -= 1) {
47+ const entry = entries[index];
48+ if (!entry || entry.structuredPayload.type !== "compaction") {
5649 continue;
5750 }
58−
59− const payload = asRecord(activity.payload);
60− const usedTokens = asFiniteNumber(payload?.usedTokens);
51+ const payload = entry.structuredPayload;
52+ const usedTokens = asFiniteNumber(payload.afterTokenCount);
6153 if (usedTokens === null || usedTokens < 0) {
6254 continue;
6355 }
6456
65− const maxTokens = asFiniteNumber(payload?.maxTokens);
57+ const maxTokens = null;
6658 const usedPercentage =
6759 maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null;
6860 const remainingTokens =
@@ -71,24 +63,24 @@ export function deriveLatestContextWindowSnapshot( · 2 unmodified lines
7163
7264 return {
7365 usedTokens,
74− totalProcessedTokens: asFiniteNumber(payload?.totalProcessedTokens),
66+ totalProcessedTokens: asFiniteNumber(payload.beforeTokenCount),
7567 maxTokens,
7668 remainingTokens,
7769 usedPercentage,
7870 remainingPercentage,
79− inputTokens: asFiniteNumber(payload?.inputTokens),
80− cachedInputTokens: asFiniteNumber(payload?.cachedInputTokens),
81− outputTokens: asFiniteNumber(payload?.outputTokens),
82− reasoningOutputTokens: asFiniteNumber(payload?.reasoningOutputTokens),
83− lastUsedTokens: asFiniteNumber(payload?.lastUsedTokens),
84− lastInputTokens: asFiniteNumber(payload?.lastInputTokens),
85− lastCachedInputTokens: asFiniteNumber(payload?.lastCachedInputTokens),
86− lastOutputTokens: asFiniteNumber(payload?.lastOutputTokens),
87− lastReasoningOutputTokens: asFiniteNumber(payload?.lastReasoningOutputTokens),
88− toolUses: asFiniteNumber(payload?.toolUses),
89− durationMs: asFiniteNumber(payload?.durationMs),
90− compactsAutomatically: asBoolean(payload?.compactsAutomatically) ?? false,
91− updatedAt: activity.createdAt,
71+ inputTokens: null,
72+ cachedInputTokens: null,
73+ outputTokens: null,
74+ reasoningOutputTokens: null,
75+ lastUsedTokens: null,
76+ lastInputTokens: null,
77+ lastCachedInputTokens: null,
78+ lastOutputTokens: null,
79+ lastReasoningOutputTokens: null,
80+ toolUses: null,
81+ durationMs: null,
82+ compactsAutomatically: true,
83+ updatedAt: entry.createdAt,
9284 };
9385 }
9486
apps/web/src/lib/threadSort.test.ts+8−9
@@ -7,13 +7,14 @@ import { · 6 unmodified lines
77 ThreadId,
88 } from "@t3tools/contracts";
99 import type { Thread } from "../types";
10+import { makeThreadFixture } from "../test-fixtures";
1011 import { getLatestThreadForProject, sortThreads } from "./threadSort";
1112
1213 const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
1314 const PROJECT_ID = ProjectId.make("project-1");
1415
1516 function makeThread(overrides: Partial<Thread> = {}): Thread {
16− return {
17+ return makeThreadFixture({
1718 id: ThreadId.make("thread-1"),
1819 environmentId: LOCAL_ENVIRONMENT_ID,
1920 projectId: PROJECT_ID,
@@ -21,7 +22,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 1 unmodified line
2122 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
2223 runtimeMode: DEFAULT_RUNTIME_MODE,
2324 interactionMode: "default",
24− session: null,
25+ runtime: null,
2526 messages: [],
2627 proposedPlans: [],
2728 createdAt: "2026-03-09T10:00:00.000Z",
@@ -30,13 +31,11 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 2 unmodified lines
3031 settledAt: null,
3132 deletedAt: null,
3233 updatedAt: "2026-03-09T10:00:00.000Z",
33− latestTurn: null,
34+ latestRun: null,
3435 branch: null,
3536 worktreePath: null,
36− checkpoints: [],
37− activities: [],
3837 ...overrides,
39− };
38+ });
4039 }
4140
4241 describe("sortThreads", () => {
@@ -51,7 +50,7 @@ describe("sortThreads", () => { · 8 unmodified lines
5150 id: "message-1" as never,
5251 role: "user",
5352 text: "older",
54− turnId: null,
53+ runId: null,
5554 createdAt: "2026-03-09T10:01:00.000Z",
5655 updatedAt: "2026-03-09T10:01:00.000Z",
5756 streaming: false,
@@ -67,7 +66,7 @@ describe("sortThreads", () => { · 9 unmodified lines
6766 id: "message-2" as never,
6867 role: "user",
6968 text: "newer",
70− turnId: null,
69+ runId: null,
7170 createdAt: "2026-03-09T10:06:00.000Z",
7271 updatedAt: "2026-03-09T10:06:00.000Z",
7372 streaming: false,
@@ -95,7 +94,7 @@ describe("sortThreads", () => { · 21 unmodified lines
9594 id: "message-1" as never,
9695 role: "assistant",
9796 text: "assistant only",
98− turnId: null,
97+ runId: null,
9998 createdAt: "2026-03-09T10:02:00.000Z",
10099 updatedAt: "2026-03-09T10:02:00.000Z",
101100 streaming: false,
apps/web/src/session-logic.test.ts+123−1652
@@ -1,1688 +1,159 @@
11 import {
2− EventId,
32 MessageId,
3+ PlanId,
4+ RunId,
45 ThreadId,
5− TurnId,
6− type OrchestrationThreadActivity,
6+ TurnItemId,
7+ type OrchestrationV2ProjectedTurnItem,
8+ type OrchestrationV2TurnItem,
79 } from "@t3tools/contracts";
10+import * as DateTime from "effect/DateTime";
811 import { describe, expect, it } from "vite-plus/test";
912
1013 import {
11− deriveActiveWorkStartedAt,
12− deriveActivePlanState,
13− derivePendingApprovals,
14− derivePendingUserInputs,
1514 deriveTimelineEntries,
16− deriveWorkLogEntries,
15+ deriveTimelineEntriesFromVisibleTurnItems,
1716 findLatestProposedPlan,
18− findSidebarProposedPlan,
19− hasActionableProposedPlan,
20− isLatestTurnSettled,
21− workEntryIndicatesToolFailure,
22− workEntryIndicatesToolNeutralStatus,
23− workEntryIndicatesToolSuccess,
17+ isLatestRunSettled,
2418 } from "./session-logic";
2519
26−let nextActivityId = 0;
27−
28−function makeActivity(overrides: {
29− id?: string;
30− createdAt?: string;
31− kind?: string;
32− summary?: string;
33− tone?: OrchestrationThreadActivity["tone"];
34− payload?: Record<string, unknown>;
35− turnId?: string;
36− sequence?: number;
37−}): OrchestrationThreadActivity {
38− const payload = overrides.payload ?? {};
39− return {
40− id: EventId.make(overrides.id ?? `activity-${nextActivityId++}`),
41− createdAt: overrides.createdAt ?? "2026-02-23T00:00:00.000Z",
42− kind: overrides.kind ?? "tool.started",
43− summary: overrides.summary ?? "Tool call",
44− tone: overrides.tone ?? "tool",
45− payload,
46− turnId: overrides.turnId ? TurnId.make(overrides.turnId) : null,
47− ...(overrides.sequence !== undefined ? { sequence: overrides.sequence } : {}),
48− };
49−}
50−
51−describe("derivePendingApprovals", () => {
52− it("tracks open approvals and removes resolved ones", () => {
53− const activities: OrchestrationThreadActivity[] = [
54− makeActivity({
55− id: "approval-open",
56− createdAt: "2026-02-23T00:00:01.000Z",
57− kind: "approval.requested",
58− summary: "Command approval requested",
59− tone: "approval",
60− payload: {
61− requestId: "req-1",
62− requestKind: "command",
63− detail: "bun run lint",
64− },
65− }),
66− makeActivity({
67− id: "approval-close",
68− createdAt: "2026-02-23T00:00:02.000Z",
69− kind: "approval.resolved",
70− summary: "Approval resolved",
71− tone: "info",
72− payload: { requestId: "req-2" },
73− }),
74− makeActivity({
75− id: "approval-closed-request",
76− createdAt: "2026-02-23T00:00:01.500Z",
77− kind: "approval.requested",
78− summary: "File-change approval requested",
79− tone: "approval",
80− payload: { requestId: "req-2", requestKind: "file-change" },
81− }),
82− ];
83−
84− expect(derivePendingApprovals(activities)).toEqual([
85− {
86− requestId: "req-1",
87− requestKind: "command",
88− createdAt: "2026-02-23T00:00:01.000Z",
89− detail: "bun run lint",
90− },
91− ]);
92− });
93−
94− it("maps canonical requestType payloads into pending approvals", () => {
95− const activities: OrchestrationThreadActivity[] = [
96− makeActivity({
97− id: "approval-open-request-type",
98− createdAt: "2026-02-23T00:00:01.000Z",
99− kind: "approval.requested",
100− summary: "Command approval requested",
101− tone: "approval",
102− payload: {
103− requestId: "req-request-type",
104− requestType: "command_execution_approval",
105− detail: "pwd",
106− },
107− }),
108− ];
109−
110− expect(derivePendingApprovals(activities)).toEqual([
111− {
112− requestId: "req-request-type",
113− requestKind: "command",
114− createdAt: "2026-02-23T00:00:01.000Z",
115− detail: "pwd",
116− },
117− ]);
118− });
119−
120− it("clears stale pending approvals when provider reports unknown pending request", () => {
121− const activities: OrchestrationThreadActivity[] = [
122− makeActivity({
123− id: "approval-open-stale",
124− createdAt: "2026-02-23T00:00:01.000Z",
125− kind: "approval.requested",
126− summary: "Command approval requested",
127− tone: "approval",
128− payload: {
129− requestId: "req-stale-1",
130− requestKind: "command",
131− },
132− }),
133− makeActivity({
134− id: "approval-failed-stale",
135− createdAt: "2026-02-23T00:00:02.000Z",
136− kind: "provider.approval.respond.failed",
137− summary: "Provider approval response failed",
138− tone: "error",
139− payload: {
140− requestId: "req-stale-1",
141− detail: "Unknown pending permission request: req-stale-1",
142− },
143− }),
144− ];
145−
146− expect(derivePendingApprovals(activities)).toEqual([]);
147− });
148−
149− it("clears stale pending approvals when the backend marks them stale after restart", () => {
150− const activities: OrchestrationThreadActivity[] = [
151− makeActivity({
152− id: "approval-open-stale-restart",
153− createdAt: "2026-02-23T00:00:01.000Z",
154− kind: "approval.requested",
155− summary: "Command approval requested",
156− tone: "approval",
157− payload: {
158− requestId: "req-stale-restart-1",
159− requestKind: "command",
160− },
161− }),
162− makeActivity({
163− id: "approval-failed-stale-restart",
164− createdAt: "2026-02-23T00:00:02.000Z",
165− kind: "provider.approval.respond.failed",
166− summary: "Provider approval response failed",
167− tone: "error",
168− payload: {
169− requestId: "req-stale-restart-1",
170− detail:
171− "Stale pending approval request: req-stale-restart-1. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.",
172− },
173− }),
174− ];
175−
176− expect(derivePendingApprovals(activities)).toEqual([]);
177− });
178−});
179−
180−describe("derivePendingUserInputs", () => {
181− it("tracks open structured prompts and removes resolved ones", () => {
182− const activities: OrchestrationThreadActivity[] = [
183− makeActivity({
184− id: "user-input-open",
185− createdAt: "2026-02-23T00:00:01.000Z",
186− kind: "user-input.requested",
187− summary: "User input requested",
188− tone: "info",
189− payload: {
190− requestId: "req-user-input-1",
191− questions: [
192− {
193− id: "sandbox_mode",
194− header: "Sandbox",
195− question: "Which mode should be used?",
196− options: [
197− {
198− label: "workspace-write",
199− description: "Allow workspace writes only",
200− },
201− ],
202− multiSelect: true,
203− },
204− ],
205− },
206− }),
207− makeActivity({
208− id: "user-input-resolved",
209− createdAt: "2026-02-23T00:00:02.000Z",
210− kind: "user-input.resolved",
211− summary: "User input submitted",
212− tone: "info",
213− payload: {
214− requestId: "req-user-input-2",
215− answers: {
216− sandbox_mode: "workspace-write",
217− },
218− },
219− }),
220− makeActivity({
221− id: "user-input-open-2",
222− createdAt: "2026-02-23T00:00:01.500Z",
223− kind: "user-input.requested",
224− summary: "User input requested",
225− tone: "info",
226− payload: {
227− requestId: "req-user-input-2",
228− questions: [
229− {
230− id: "approval",
231− header: "Approval",
232− question: "Continue?",
233− options: [
234− {
235− label: "yes",
236− description: "Continue execution",
237− },
238− ],
239− multiSelect: false,
240− },
241− ],
242− },
243− }),
244− ];
245−
246− expect(derivePendingUserInputs(activities)).toEqual([
247− {
248− requestId: "req-user-input-1",
249− createdAt: "2026-02-23T00:00:01.000Z",
250− questions: [
251− {
252− id: "sandbox_mode",
253− header: "Sandbox",
254− question: "Which mode should be used?",
255− options: [
256− {
257− label: "workspace-write",
258− description: "Allow workspace writes only",
259− },
260− ],
261− multiSelect: true,
262− },
263− ],
264− },
265− ]);
266− });
267−
268− it("clears stale pending user-input prompts when the provider reports an orphaned request", () => {
269− const activities: OrchestrationThreadActivity[] = [
270− makeActivity({
271− id: "user-input-open-stale",
272− createdAt: "2026-02-23T00:00:01.000Z",
273− kind: "user-input.requested",
274− summary: "User input requested",
275− tone: "info",
276− payload: {
277− requestId: "req-user-input-stale-1",
278− questions: [
279− {
280− id: "sandbox_mode",
281− header: "Sandbox",
282− question: "Which mode should be used?",
283− options: [
284− {
285− label: "workspace-write",
286− description: "Allow workspace writes only",
287− },
288− ],
289− multiSelect: false,
290− },
291− ],
292− },
293− }),
294− makeActivity({
295− id: "user-input-failed-stale",
296− createdAt: "2026-02-23T00:00:02.000Z",
297− kind: "provider.user-input.respond.failed",
298− summary: "Provider user input response failed",
299− tone: "error",
300− payload: {
301− requestId: "req-user-input-stale-1",
302− detail:
303− "Provider adapter request failed (codex) for item/tool/requestUserInput: Unknown pending Codex user input request: req-user-input-stale-1",
304− },
305− }),
306− ];
307−
308− expect(derivePendingUserInputs(activities)).toEqual([]);
309− });
310−});
311−
312−describe("deriveActivePlanState", () => {
313− it("returns the latest plan update for the active turn", () => {
314− const activities: OrchestrationThreadActivity[] = [
315− makeActivity({
316− id: "plan-old",
317− createdAt: "2026-02-23T00:00:01.000Z",
318− kind: "turn.plan.updated",
319− summary: "Plan updated",
320− tone: "info",
321− turnId: "turn-1",
322− payload: {
323− explanation: "Initial plan",
324− plan: [{ step: "Inspect code", status: "pending" }],
325− },
326− }),
327− makeActivity({
328− id: "plan-latest",
329− createdAt: "2026-02-23T00:00:02.000Z",
330− kind: "turn.plan.updated",
331− summary: "Plan updated",
332− tone: "info",
333− turnId: "turn-1",
334− payload: {
335− explanation: "Refined plan",
336− plan: [{ step: "Implement Codex user input", status: "inProgress" }],
337− },
338− }),
339− ];
340−
341− expect(deriveActivePlanState(activities, TurnId.make("turn-1"))).toEqual({
342− createdAt: "2026-02-23T00:00:02.000Z",
343− turnId: "turn-1",
344− explanation: "Refined plan",
345− steps: [{ step: "Implement Codex user input", status: "inProgress" }],
346− });
347− });
348−
349− it("falls back to the most recent plan from a previous turn", () => {
350− const activities: OrchestrationThreadActivity[] = [
351− makeActivity({
352− id: "plan-from-turn-1",
353− createdAt: "2026-02-23T00:00:01.000Z",
354− kind: "turn.plan.updated",
355− summary: "Plan updated",
356− tone: "info",
357− turnId: "turn-1",
358− payload: {
359− plan: [{ step: "Write tests", status: "completed" }],
20+describe("V2 session presentation", () => {
21+ it("uses run status as the settlement boundary", () => {
22+ const runId = RunId.make("run-1");
23+ expect(
24+ isLatestRunSettled(
25+ {
26+ runId,
27+ status: "completed",
28+ startedAt: "2026-06-20T00:00:00.000Z",
29+ completedAt: "2026-06-20T00:01:00.000Z",
36030 },
361− }),
362− ];
363−
364− // Current turn is turn-2, which has no plan activity — should fall back to turn-1's plan
365− const result = deriveActivePlanState(activities, TurnId.make("turn-2"));
366− expect(result).toEqual({
367− createdAt: "2026-02-23T00:00:01.000Z",
368− turnId: "turn-1",
369− steps: [{ step: "Write tests", status: "completed" }],
370− });
371− });
372−});
373−
374−describe("findLatestProposedPlan", () => {
375− it("prefers the latest proposed plan for the active turn", () => {
31+ null,
32+ ),
33+ ).toBe(true);
37634 expect(
377− findLatestProposedPlan(
378− [
379− {
380− id: "plan:thread-1:turn:turn-1",
381− turnId: TurnId.make("turn-1"),
382− planMarkdown: "# Older",
383− implementedAt: null,
384− implementationThreadId: null,
385− createdAt: "2026-02-23T00:00:01.000Z",
386− updatedAt: "2026-02-23T00:00:01.000Z",
387− },
388− {
389− id: "plan:thread-1:turn:turn-1",
390− turnId: TurnId.make("turn-1"),
391− planMarkdown: "# Latest",
392− implementedAt: null,
393− implementationThreadId: null,
394− createdAt: "2026-02-23T00:00:01.000Z",
395− updatedAt: "2026-02-23T00:00:02.000Z",
396− },
397− {
398− id: "plan:thread-1:turn:turn-2",
399− turnId: TurnId.make("turn-2"),
400− planMarkdown: "# Different turn",
401− implementedAt: null,
402− implementationThreadId: null,
403− createdAt: "2026-02-23T00:00:03.000Z",
404− updatedAt: "2026-02-23T00:00:03.000Z",
405− },
406− ],
407− TurnId.make("turn-1"),
35+ isLatestRunSettled(
36+ { runId, status: "running", startedAt: null, completedAt: null },
37+ { status: "running", activeRunId: runId },
40838 ),
409− ).toEqual({
410− id: "plan:thread-1:turn:turn-1",
411− turnId: "turn-1",
412− planMarkdown: "# Latest",
413− implementedAt: null,
414− implementationThreadId: null,
415− createdAt: "2026-02-23T00:00:01.000Z",
416− updatedAt: "2026-02-23T00:00:02.000Z",
417− });
39+ ).toBe(false);
41840 });
41941
420− it("falls back to the most recently updated proposed plan", () => {
421− const latestPlan = findLatestProposedPlan(
42+ it("selects the latest proposed plan for a run", () => {
43+ const runId = RunId.make("run-1");
44+ const plan = findLatestProposedPlan(
42245 [
42346 {
424− id: "plan:thread-1:turn:turn-1",
425− turnId: TurnId.make("turn-1"),
426− planMarkdown: "# First",
47+ id: PlanId.make("plan-1"),
48+ runId,
49+ planMarkdown: "Plan",
50+ status: "active",
42751 implementedAt: null,
428− implementationThreadId: null,
429− createdAt: "2026-02-23T00:00:01.000Z",
430− updatedAt: "2026-02-23T00:00:01.000Z",
431− },
432− {
433− id: "plan:thread-1:turn:turn-2",
434− turnId: TurnId.make("turn-2"),
435− planMarkdown: "# Latest",
436− implementedAt: null,
437− implementationThreadId: null,
438− createdAt: "2026-02-23T00:00:02.000Z",
439− updatedAt: "2026-02-23T00:00:03.000Z",
52+ implementationThreadId: ThreadId.make("thread-implementation"),
53+ createdAt: "2026-06-20T00:00:00.000Z",
54+ updatedAt: "2026-06-20T00:00:01.000Z",
44055 },
44156 ],
442− null,
443− );
444−
445− expect(latestPlan?.planMarkdown).toBe("# Latest");
446− });
447−});
448−
449−describe("hasActionableProposedPlan", () => {
450− it("returns true for an unimplemented proposed plan", () => {
451− expect(
452− hasActionableProposedPlan({
453− id: "plan-1",
454− turnId: TurnId.make("turn-1"),
455− planMarkdown: "# Plan",
456− implementedAt: null,
457− implementationThreadId: null,
458− createdAt: "2026-02-23T00:00:00.000Z",
459− updatedAt: "2026-02-23T00:00:01.000Z",
460− }),
461− ).toBe(true);
462− });
463−
464− it("returns false for a proposed plan already implemented elsewhere", () => {
465− expect(
466− hasActionableProposedPlan({
467− id: "plan-1",
468− turnId: TurnId.make("turn-1"),
469− planMarkdown: "# Plan",
470− implementedAt: "2026-02-23T00:00:02.000Z",
471− implementationThreadId: ThreadId.make("thread-implement"),
472− createdAt: "2026-02-23T00:00:00.000Z",
473− updatedAt: "2026-02-23T00:00:02.000Z",
474− }),
475− ).toBe(false);
476− });
477−});
478−
479−describe("findSidebarProposedPlan", () => {
480− it("prefers the running turn source proposed plan when available on the same thread", () => {
481− expect(
482− findSidebarProposedPlan({
483− threads: [
484− {
485− id: ThreadId.make("thread-1"),
486− proposedPlans: [
487− {
488− id: "plan-1",
489− turnId: TurnId.make("turn-plan"),
490− planMarkdown: "# Source plan",
491− implementedAt: "2026-02-23T00:00:03.000Z",
492− implementationThreadId: ThreadId.make("thread-2"),
493− createdAt: "2026-02-23T00:00:01.000Z",
494− updatedAt: "2026-02-23T00:00:02.000Z",
495− },
496− ],
497− },
498− {
499− id: ThreadId.make("thread-2"),
500− proposedPlans: [
501− {
502− id: "plan-2",
503− turnId: TurnId.make("turn-other"),
504− planMarkdown: "# Latest elsewhere",
505− implementedAt: null,
506− implementationThreadId: null,
507− createdAt: "2026-02-23T00:00:04.000Z",
508− updatedAt: "2026-02-23T00:00:05.000Z",
509− },
510− ],
511− },
512− ],
513− latestTurn: {
514− turnId: TurnId.make("turn-implementation"),
515− sourceProposedPlan: {
516− threadId: ThreadId.make("thread-1"),
517− planId: "plan-1",
518− },
519− },
520− latestTurnSettled: false,
521− threadId: ThreadId.make("thread-1"),
522− }),
523− ).toEqual({
524− id: "plan-1",
525− turnId: "turn-plan",
526− planMarkdown: "# Source plan",
527− implementedAt: "2026-02-23T00:00:03.000Z",
528− implementationThreadId: "thread-2",
529− createdAt: "2026-02-23T00:00:01.000Z",
530− updatedAt: "2026-02-23T00:00:02.000Z",
531− });
532− });
533−
534− it("falls back to the latest proposed plan once the turn is settled", () => {
535− expect(
536− findSidebarProposedPlan({
537− threads: [
538− {
539− id: ThreadId.make("thread-1"),
540− proposedPlans: [
541− {
542− id: "plan-1",
543− turnId: TurnId.make("turn-plan"),
544− planMarkdown: "# Older",
545− implementedAt: null,
546− implementationThreadId: null,
547− createdAt: "2026-02-23T00:00:01.000Z",
548− updatedAt: "2026-02-23T00:00:02.000Z",
549− },
550− {
551− id: "plan-2",
552− turnId: TurnId.make("turn-latest"),
553− planMarkdown: "# Latest",
554− implementedAt: null,
555− implementationThreadId: null,
556− createdAt: "2026-02-23T00:00:03.000Z",
557− updatedAt: "2026-02-23T00:00:04.000Z",
558− },
559− ],
560− },
561− ],
562− latestTurn: {
563− turnId: TurnId.make("turn-implementation"),
564− sourceProposedPlan: {
565− threadId: ThreadId.make("thread-1"),
566− planId: "plan-1",
567− },
568− },
569− latestTurnSettled: true,
570− threadId: ThreadId.make("thread-1"),
571− })?.planMarkdown,
572− ).toBe("# Latest");
573− });
574−});
575−
576−describe("workEntryIndicatesToolFailure", () => {
577− const base = {
578− id: "w1",
579− createdAt: "2026-01-01T00:00:00.000Z",
580− label: "Read",
581− };
582−
583− it("is true for error tone", () => {
584− expect(
585− workEntryIndicatesToolFailure({
586− ...base,
587− tone: "error",
588− detail: "nothing special",
589− }),
590− ).toBe(true);
591− });
592−
593− it("is true when lifecycle says failed even if detail is empty", () => {
594− expect(
595− workEntryIndicatesToolFailure({
596− ...base,
597− tone: "tool",
598− toolLifecycleStatus: "failed",
599− }),
600− ).toBe(true);
601− });
602−
603− it("detects file-not-found style tool output with completed lifecycle", () => {
604− expect(
605− workEntryIndicatesToolFailure({
606− ...base,
607− tone: "tool",
608− toolLifecycleStatus: "completed",
609− detail: "File not found: C:\\foo\\nonexistent.ts",
610− }),
611− ).toBe(true);
612− });
613−
614− it("detects glob no files and PowerShell command errors", () => {
615− expect(
616− workEntryIndicatesToolFailure({
617− ...base,
618− label: "Glob",
619− tone: "tool",
620− detail: "No files found",
621− }),
622− ).toBe(true);
623− expect(
624− workEntryIndicatesToolFailure({
625− ...base,
626− label: "Bash",
627− tone: "tool",
628− detail:
629− "The term 'this_is_not_a_command' is not recognized as the name of a cmdlet, function, script file, or operable program.",
630− }),
631− ).toBe(true);
632− });
633−
634− it("is false for successful completed tools", () => {
635− expect(
636− workEntryIndicatesToolFailure({
637− ...base,
638− tone: "tool",
639− toolLifecycleStatus: "completed",
640− detail: "Found 3 matching files",
641− }),
642− ).toBe(false);
643− });
644−
645− it("treats successful tool rows as success candidates", () => {
646− expect(
647− workEntryIndicatesToolSuccess({
648− ...base,
649− tone: "tool",
650− toolLifecycleStatus: "completed",
651− detail: "ok",
652− }),
653− ).toBe(true);
654− expect(
655− workEntryIndicatesToolSuccess({
656− ...base,
657− tone: "tool",
658− toolLifecycleStatus: "inProgress",
659− detail: "…",
660− }),
661− ).toBe(false);
662− expect(workEntryIndicatesToolSuccess({ ...base, tone: "thinking", detail: "…" })).toBe(false);
663− expect(
664− workEntryIndicatesToolNeutralStatus({
665− ...base,
666− tone: "tool",
667− toolLifecycleStatus: "inProgress",
668− detail: "…",
669− }),
670− ).toBe(true);
671− expect(
672− workEntryIndicatesToolNeutralStatus({
673− ...base,
674− tone: "tool",
675− toolLifecycleStatus: "completed",
676− detail: "ok",
677− }),
678− ).toBe(false);
679− });
680−
681− it("does not run heuristics on non-tool info rows", () => {
682− expect(
683− workEntryIndicatesToolFailure({
684− ...base,
685− label: "Context compacted",
686− tone: "info",
687− detail: "File not found in conversation",
688− }),
689− ).toBe(false);
690− });
691−});
692−
693−describe("deriveWorkLogEntries", () => {
694− it("omits tool started entries and keeps completed entries", () => {
695− const activities: OrchestrationThreadActivity[] = [
696− makeActivity({
697− id: "tool-complete",
698− createdAt: "2026-02-23T00:00:03.000Z",
699− summary: "Tool call complete",
700− kind: "tool.completed",
701− }),
702− makeActivity({
703− id: "tool-start",
704− createdAt: "2026-02-23T00:00:02.000Z",
705− summary: "Tool call",
706− kind: "tool.started",
707− }),
708− ];
709−
710− const entries = deriveWorkLogEntries(activities);
711− expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);
712− });
713−
714− it("omits task.started but shows task.progress and task.completed", () => {
715− const activities: OrchestrationThreadActivity[] = [
716− makeActivity({
717− id: "task-start",
718− createdAt: "2026-02-23T00:00:01.000Z",
719− kind: "task.started",
720− summary: "default task started",
721− tone: "info",
722− }),
723− makeActivity({
724− id: "task-progress",
725− createdAt: "2026-02-23T00:00:02.000Z",
726− kind: "task.progress",
727− summary: "Updating files",
728− tone: "info",
729− }),
730− makeActivity({
731− id: "task-complete",
732− createdAt: "2026-02-23T00:00:03.000Z",
733− kind: "task.completed",
734− summary: "Task completed",
735− tone: "info",
736− }),
737− ];
738−
739− const entries = deriveWorkLogEntries(activities);
740− expect(entries.map((entry) => entry.id)).toEqual(["task-progress", "task-complete"]);
741− });
742−
743− it("uses payload summary as label for task entries when available", () => {
744− const activities: OrchestrationThreadActivity[] = [
745− makeActivity({
746− id: "task-progress-with-summary",
747− createdAt: "2026-02-23T00:00:02.000Z",
748− kind: "task.progress",
749− summary: "Reasoning update",
750− tone: "info",
751− payload: { summary: "Searching for API endpoints" },
752− }),
753− ];
754−
755− const entries = deriveWorkLogEntries(activities);
756− expect(entries[0]?.label).toBe("Searching for API endpoints");
757− });
758−
759− it("uses payload detail as label for task.completed and preserves error tone", () => {
760− const activities: OrchestrationThreadActivity[] = [
761− makeActivity({
762− id: "task-completed-failed",
763− createdAt: "2026-02-23T00:00:03.000Z",
764− kind: "task.completed",
765− summary: "Task failed",
766− tone: "error",
767− payload: { detail: "Failed to deploy changes" },
768− }),
769− ];
770−
771− const entries = deriveWorkLogEntries(activities);
772− expect(entries[0]?.label).toBe("Failed to deploy changes");
773− expect(entries[0]?.tone).toBe("error");
774− });
775−
776− it("keeps tool entries from every turn and tags each with its turn id", () => {
777− const activities: OrchestrationThreadActivity[] = [
778− makeActivity({
779− id: "turn-1-tool",
780− turnId: "turn-1",
781− summary: "Tool call complete",
782− kind: "tool.completed",
783− }),
784− makeActivity({
785− id: "turn-2-tool",
786− turnId: "turn-2",
787− summary: "Tool call complete",
788− kind: "tool.completed",
789− }),
790− ];
791−
792− const entries = deriveWorkLogEntries(activities);
793− expect(entries.map((entry) => entry.id)).toEqual(["turn-1-tool", "turn-2-tool"]);
794− expect(entries.map((entry) => entry.turnId)).toEqual([
795− TurnId.make("turn-1"),
796− TurnId.make("turn-2"),
797− ]);
798− });
799−
800− it("omits checkpoint captured info entries", () => {
801− const activities: OrchestrationThreadActivity[] = [
802− makeActivity({
803− id: "checkpoint",
804− createdAt: "2026-02-23T00:00:01.000Z",
805− summary: "Checkpoint captured",
806− tone: "info",
807− }),
808− makeActivity({
809− id: "tool-complete",
810− createdAt: "2026-02-23T00:00:02.000Z",
811− summary: "Ran command",
812− tone: "tool",
813− kind: "tool.completed",
814− }),
815− ];
816−
817− const entries = deriveWorkLogEntries(activities);
818− expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);
819− });
820−
821− it("omits ExitPlanMode lifecycle entries once the plan card is shown", () => {
822− const activities: OrchestrationThreadActivity[] = [
823− makeActivity({
824− id: "exit-plan-updated",
825− createdAt: "2026-02-23T00:00:01.000Z",
826− kind: "tool.updated",
827− summary: "Tool call",
828− payload: {
829− detail: 'ExitPlanMode: {"allowedPrompts":[{"tool":"Bash","prompt":"run tests"}]}',
830− },
831− }),
832− makeActivity({
833− id: "exit-plan-completed",
834− createdAt: "2026-02-23T00:00:02.000Z",
835− kind: "tool.completed",
836− summary: "Tool call",
837− payload: {
838− detail: "ExitPlanMode: {}",
839− },
840− }),
841− makeActivity({
842− id: "real-work-log",
843− createdAt: "2026-02-23T00:00:03.000Z",
844− kind: "tool.completed",
845− summary: "Ran command",
846− payload: {
847− itemType: "command_execution",
848− detail: "Bash: bun test",
849− },
850− }),
851− ];
852−
853− const entries = deriveWorkLogEntries(activities);
854− expect(entries.map((entry) => entry.id)).toEqual(["real-work-log"]);
855− });
856−
857− it("orders work log by activity sequence when present", () => {
858− const activities: OrchestrationThreadActivity[] = [
859− makeActivity({
860− id: "second",
861− createdAt: "2026-02-23T00:00:03.000Z",
862− sequence: 2,
863− summary: "Tool call complete",
864− kind: "tool.completed",
865− }),
866− makeActivity({
867− id: "first",
868− createdAt: "2026-02-23T00:00:04.000Z",
869− sequence: 1,
870− summary: "Tool call complete",
871− kind: "tool.completed",
872− }),
873− ];
874−
875− const entries = deriveWorkLogEntries(activities);
876− expect(entries.map((entry) => entry.id)).toEqual(["first", "second"]);
877− });
878−
879− it("extracts command text for command tool activities", () => {
880− const activities: OrchestrationThreadActivity[] = [
881− makeActivity({
882− id: "command-tool",
883− kind: "tool.completed",
884− summary: "Ran command",
885− payload: {
886− itemType: "command_execution",
887− data: {
888− item: {
889− command: ["bun", "run", "lint"],
890− },
891− },
892− },
893− }),
894− ];
895−
896− const [entry] = deriveWorkLogEntries(activities);
897− expect(entry?.command).toBe("bun run lint");
898− });
899−
900− it("extracts failed tool lifecycle status from item payloads", () => {
901− const activities: OrchestrationThreadActivity[] = [
902− makeActivity({
903− id: "tool-failed",
904− kind: "tool.updated",
905− summary: "Glob",
906− tone: "tool",
907− payload: {
908− itemType: "mcp_tool_call",
909− status: "failed",
910− detail: "No files found",
911− },
912− }),
913− ];
914−
915− const [entry] = deriveWorkLogEntries(activities);
916− expect(entry?.toolLifecycleStatus).toBe("failed");
917− });
918−
919− it("defaults tool.completed entries to completed lifecycle status", () => {
920− const activities: OrchestrationThreadActivity[] = [
921− makeActivity({
922− id: "tool-done",
923− kind: "tool.completed",
924− summary: "Glob",
925− tone: "tool",
926− payload: {
927− itemType: "mcp_tool_call",
928− detail: "Found 3 files",
929− },
930− }),
931− ];
932−
933− const [entry] = deriveWorkLogEntries(activities);
934− expect(entry?.toolLifecycleStatus).toBe("completed");
935− });
936−
937− it("preserves MCP server, tool, arguments, and results for expanded display", () => {
938− const item = {
939− type: "mcpToolCall",
940− server: "t3-code",
941− tool: "preview_status",
942− arguments: {},
943− status: "completed",
944− result: { content: [{ type: "text", text: "attached" }] },
945− };
946− const activities: OrchestrationThreadActivity[] = [
947− makeActivity({
948− id: "mcp-tool-done",
949− kind: "tool.completed",
950− summary: "t3-code · preview_status",
951− payload: {
952− itemType: "mcp_tool_call",
953− title: "t3-code · preview_status",
954− data: { item },
955− },
956− }),
957− ];
958−
959− const [entry] = deriveWorkLogEntries(activities);
960− expect(entry?.toolTitle).toBe("t3-code · preview_status");
961− expect(entry?.toolData).toEqual(item);
962− });
963−
964− it("keeps MCP payloads while collapsing lifecycle updates", () => {
965− const item = {
966− type: "mcpToolCall",
967− server: "t3-code",
968− tool: "preview_snapshot",
969− arguments: { interactiveOnly: true },
970− status: "completed",
971− };
972− const activities: OrchestrationThreadActivity[] = [
973− makeActivity({
974− id: "mcp-tool-progress",
975− kind: "tool.updated",
976− summary: "t3-code · preview_snapshot",
977− payload: {
978− itemType: "mcp_tool_call",
979− toolCallId: "call-1",
980− data: { item },
981− },
982− }),
983− makeActivity({
984− id: "mcp-tool-complete",
985− kind: "tool.completed",
986− summary: "t3-code · preview_snapshot",
987− payload: {
988− itemType: "mcp_tool_call",
989− toolCallId: "call-1",
990− },
991− }),
992− ];
993−
994− const [entry] = deriveWorkLogEntries(activities);
995− expect(entry?.toolData).toEqual(item);
996− });
997−
998− it("unwraps PowerShell command wrappers for displayed command text", () => {
999− const activities: OrchestrationThreadActivity[] = [
1000− makeActivity({
1001− id: "command-tool-windows-wrapper",
1002− kind: "tool.completed",
1003− summary: "Ran command",
1004− payload: {
1005− itemType: "command_execution",
1006− data: {
1007− item: {
1008− command: "\"C:\\Program Files\\PowerShell\\7\\pwsh.exe\" -Command 'bun run lint'",
1009− },
1010− },
1011− },
1012− }),
1013− ];
1014−
1015− const [entry] = deriveWorkLogEntries(activities);
1016− expect(entry?.command).toBe("bun run lint");
1017− expect(entry?.rawCommand).toBe(
1018− "\"C:\\Program Files\\PowerShell\\7\\pwsh.exe\" -Command 'bun run lint'",
1019− );
1020− });
1021−
1022− it("unwraps PowerShell command wrappers from argv-style command payloads", () => {
1023− const activities: OrchestrationThreadActivity[] = [
1024− makeActivity({
1025− id: "command-tool-windows-wrapper-argv",
1026− kind: "tool.completed",
1027− summary: "Ran command",
1028− payload: {
1029− itemType: "command_execution",
1030− data: {
1031− item: {
1032− command: ["C:\\Program Files\\PowerShell\\7\\pwsh.exe", "-Command", "rg -n foo ."],
1033− },
1034− },
1035− },
1036− }),
1037− ];
1038−
1039− const [entry] = deriveWorkLogEntries(activities);
1040− expect(entry?.command).toBe("rg -n foo .");
1041− expect(entry?.rawCommand).toBe(
1042− '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -Command "rg -n foo ."',
1043− );
1044− });
1045−
1046− it("extracts command text from command detail when structured command metadata is missing", () => {
1047− const activities: OrchestrationThreadActivity[] = [
1048− makeActivity({
1049− id: "command-tool-windows-detail-fallback",
1050− kind: "tool.completed",
1051− summary: "Ran command",
1052− payload: {
1053− itemType: "command_execution",
1054− detail:
1055− '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoLogo -NoProfile -Command \'rg -n -F "new Date()" .\' <exited with exit code 0>',
1056− },
1057− }),
1058− ];
1059−
1060− const [entry] = deriveWorkLogEntries(activities);
1061− expect(entry?.command).toBe('rg -n -F "new Date()" .');
1062− expect(entry?.rawCommand).toBe(
1063− `"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoLogo -NoProfile -Command 'rg -n -F "new Date()" .'`,
57+ runId,
106458 );
59+ expect(plan?.planMarkdown).toBe("Plan");
106560 });
106661
1067− it("does not unwrap shell commands when no wrapper flag is present", () => {
1068− const activities: OrchestrationThreadActivity[] = [
1069− makeActivity({
1070− id: "command-tool-shell-script",
1071− kind: "tool.completed",
1072− summary: "Ran command",
1073− payload: {
1074− itemType: "command_execution",
1075− data: {
1076− item: {
1077− command: "bash script.sh",
1078− },
1079− },
1080− },
1081− }),
1082− ];
1083−
1084− const [entry] = deriveWorkLogEntries(activities);
1085− expect(entry?.command).toBe("bash script.sh");
1086− expect(entry?.rawCommand).toBeUndefined();
1087− });
1088−
1089− it("keeps compact Codex tool metadata used for icons and labels", () => {
1090− const activities: OrchestrationThreadActivity[] = [
1091− makeActivity({
1092− id: "tool-with-metadata",
1093− kind: "tool.completed",
1094− summary: "bash",
1095− payload: {
1096− itemType: "command_execution",
1097− title: "bash",
1098− status: "completed",
1099− detail: '{ "dev": "vite dev --port 3000" } <exited with exit code 0>',
1100− data: {
1101− item: {
1102− command: ["bun", "run", "dev"],
1103− result: {
1104− content: '{ "dev": "vite dev --port 3000" } <exited with exit code 0>',
1105− exitCode: 0,
1106− },
1107− },
1108− },
1109− },
1110− }),
1111− ];
1112−
1113− const [entry] = deriveWorkLogEntries(activities);
1114− expect(entry).toMatchObject({
1115− command: "bun run dev",
1116− detail: '{ "dev": "vite dev --port 3000" }',
1117− itemType: "command_execution",
1118− toolTitle: "bash",
1119− });
1120− });
1121−
1122− it("extracts changed file paths for file-change tool activities", () => {
1123− const activities: OrchestrationThreadActivity[] = [
1124− makeActivity({
1125− id: "file-tool",
1126− kind: "tool.completed",
1127− summary: "File change",
1128− payload: {
1129− itemType: "file_change",
1130− data: {
1131− item: {
1132− changes: [
1133− { path: "apps/web/src/components/ChatView.tsx" },
1134− { filename: "apps/web/src/session-logic.ts" },
1135− ],
1136− },
1137− },
1138− },
1139− }),
1140− ];
1141−
1142− const [entry] = deriveWorkLogEntries(activities);
1143− expect(entry?.changedFiles).toEqual([
1144− "apps/web/src/components/ChatView.tsx",
1145− "apps/web/src/session-logic.ts",
1146− ]);
1147− });
1148−
1149− it("drops duplicated tool detail when it only repeats the title", () => {
1150− const activities: OrchestrationThreadActivity[] = [
1151− makeActivity({
1152− id: "read-file-generic",
1153− kind: "tool.completed",
1154− summary: "Read File",
1155− payload: {
1156− itemType: "dynamic_tool_call",
1157− title: "Read File",
1158− detail: "Read File",
1159− },
1160− }),
1161− ];
1162−
1163− const [entry] = deriveWorkLogEntries(activities);
1164− expect(entry?.toolTitle).toBe("Read File");
1165− expect(entry?.detail).toBeUndefined();
1166− });
1167−
1168− it("uses grep raw output summaries instead of repeating the generic tool label", () => {
1169− const activities: OrchestrationThreadActivity[] = [
1170− makeActivity({
1171− id: "grep-update",
1172− createdAt: "2026-02-23T00:00:01.000Z",
1173− kind: "tool.updated",
1174− summary: "grep",
1175− payload: {
1176− itemType: "web_search",
1177− title: "grep",
1178− detail: "grep",
1179− data: {
1180− toolCallId: "tool-grep-1",
1181− kind: "search",
1182− rawInput: {},
1183− },
1184− },
1185− }),
1186− makeActivity({
1187− id: "grep-complete",
1188− createdAt: "2026-02-23T00:00:02.000Z",
1189− kind: "tool.completed",
1190− summary: "grep",
1191− payload: {
1192− itemType: "web_search",
1193− title: "grep",
1194− detail: "grep",
1195− data: {
1196− toolCallId: "tool-grep-1",
1197− kind: "search",
1198− rawOutput: {
1199− totalFiles: 19,
1200− truncated: false,
1201− },
1202− },
1203− },
1204− }),
1205− ];
1206−
1207− const entries = deriveWorkLogEntries(activities);
1208− expect(entries).toHaveLength(1);
1209− expect(entries[0]).toMatchObject({
1210− id: "grep-complete",
1211− toolTitle: "grep",
1212− detail: "19 files",
1213− itemType: "web_search",
1214− });
1215− });
1216−
1217− it("uses completed read-file output previews and still collapses the same tool call", () => {
1218− const activities: OrchestrationThreadActivity[] = [
1219− makeActivity({
1220− id: "read-update",
1221− createdAt: "2026-02-23T00:00:01.000Z",
1222− kind: "tool.updated",
1223− summary: "Read File",
1224− payload: {
1225− itemType: "dynamic_tool_call",
1226− title: "Read File",
1227− detail: "Read File",
1228− data: {
1229− toolCallId: "tool-read-1",
1230− kind: "read",
1231− rawInput: {},
1232− },
1233− },
1234− }),
1235− makeActivity({
1236− id: "read-complete",
1237− createdAt: "2026-02-23T00:00:02.000Z",
1238− kind: "tool.completed",
1239− summary: "Read File",
1240− payload: {
1241− itemType: "dynamic_tool_call",
1242− title: "Read File",
1243− detail: "Read File",
1244− data: {
1245− toolCallId: "tool-read-1",
1246− kind: "read",
1247− rawOutput: {
1248− content:
1249− 'import * as Effect from "effect/Effect"\nimport * as Layer from "effect/Layer"\n',
1250− },
1251− },
1252− },
1253− }),
1254− ];
1255−
1256− const entries = deriveWorkLogEntries(activities);
1257− expect(entries).toHaveLength(1);
1258− expect(entries[0]).toMatchObject({
1259− id: "read-complete",
1260− toolTitle: "Read File",
1261− detail: 'import * as Effect from "effect/Effect"',
1262− itemType: "dynamic_tool_call",
1263− });
1264− });
1265−
1266− it("does not use command stdout as the detail when Cursor omits the command input", () => {
1267− const activities: OrchestrationThreadActivity[] = [
1268− makeActivity({
1269− id: "cursor-command-complete",
1270− createdAt: "2026-04-16T22:40:42.221Z",
1271− kind: "tool.completed",
1272− summary: "Ran command",
1273− payload: {
1274− itemType: "command_execution",
1275− title: "Ran command",
1276− data: {
1277− toolCallId: "toolu_vrtx_01WypXgRM8PPygBtrVAZwzy5",
1278− kind: "execute",
1279− rawInput: {},
1280− rawOutput: {
1281− exitCode: 0,
1282− stdout: "total 960\napps\npackages\n",
1283− stderr: "",
1284− },
1285− },
1286− },
1287− }),
1288− ];
1289−
1290− const [entry] = deriveWorkLogEntries(activities, undefined);
1291− expect(entry).toMatchObject({
1292− id: "cursor-command-complete",
1293− label: "Ran command",
1294− itemType: "command_execution",
1295− toolTitle: "Ran command",
1296− });
1297− expect(entry?.detail).toBeUndefined();
1298− expect(entry?.command).toBeUndefined();
1299− });
1300−
1301− it("collapses legacy completed tool rows that are missing tool metadata", () => {
1302− const activities: OrchestrationThreadActivity[] = [
1303− makeActivity({
1304− id: "legacy-read-update",
1305− createdAt: "2026-02-23T00:00:01.000Z",
1306− kind: "tool.updated",
1307− summary: "Read File",
1308− payload: {
1309− itemType: "dynamic_tool_call",
1310− title: "Read File",
1311− detail: "Read File",
1312− data: {
1313− toolCallId: "tool-read-legacy",
1314− kind: "read",
1315− rawInput: {},
1316− },
1317− },
1318− }),
1319− makeActivity({
1320− id: "legacy-read-complete",
1321− createdAt: "2026-02-23T00:00:02.000Z",
1322− kind: "tool.completed",
1323− summary: "Read File",
1324− payload: {
1325− itemType: "dynamic_tool_call",
1326− title: "Read File",
1327− detail: "Read File",
1328− },
1329− }),
1330− ];
1331−
1332− const entries = deriveWorkLogEntries(activities);
1333− expect(entries).toHaveLength(1);
1334− expect(entries[0]).toMatchObject({
1335− id: "legacy-read-complete",
1336− toolTitle: "Read File",
1337− itemType: "dynamic_tool_call",
1338− });
1339− expect(entries[0]?.detail).toBeUndefined();
1340− });
1341−
1342− it("collapses repeated lifecycle updates for the same tool call into one entry", () => {
1343− const activities: OrchestrationThreadActivity[] = [
1344− makeActivity({
1345− id: "tool-update-1",
1346− createdAt: "2026-02-23T00:00:01.000Z",
1347− kind: "tool.updated",
1348− summary: "Tool call",
1349− payload: {
1350− itemType: "dynamic_tool_call",
1351− title: "Tool call",
1352− detail: 'Read: {"file_path":"/tmp/app.ts"}',
1353− },
1354− }),
1355− makeActivity({
1356− id: "tool-update-2",
1357− createdAt: "2026-02-23T00:00:02.000Z",
1358− kind: "tool.updated",
1359− summary: "Tool call",
1360− payload: {
1361− itemType: "dynamic_tool_call",
1362− title: "Tool call",
1363− detail: 'Read: {"file_path":"/tmp/app.ts"}',
1364− data: {
1365− item: {
1366− command: ["sed", "-n", "1,40p", "/tmp/app.ts"],
1367− },
1368− },
1369− },
1370− }),
1371− makeActivity({
1372− id: "tool-complete",
1373− createdAt: "2026-02-23T00:00:03.000Z",
1374− kind: "tool.completed",
1375− summary: "Tool call completed",
1376− payload: {
1377− itemType: "dynamic_tool_call",
1378− title: "Tool call",
1379− detail: 'Read: {"file_path":"/tmp/app.ts"}',
1380− },
1381− }),
1382− ];
1383−
1384− const entries = deriveWorkLogEntries(activities);
1385−
1386− expect(entries).toHaveLength(1);
1387− expect(entries[0]).toMatchObject({
1388− id: "tool-complete",
1389− createdAt: "2026-02-23T00:00:03.000Z",
1390− label: "Tool call completed",
1391− detail: 'Read: {"file_path":"/tmp/app.ts"}',
1392− command: "sed -n 1,40p /tmp/app.ts",
1393− itemType: "dynamic_tool_call",
1394− toolTitle: "Tool call",
1395− });
1396− });
1397−
1398− it("keeps separate tool entries when an identical call starts after the prior one completed", () => {
1399− const activities: OrchestrationThreadActivity[] = [
1400− makeActivity({
1401− id: "tool-1-update",
1402− createdAt: "2026-02-23T00:00:01.000Z",
1403− kind: "tool.updated",
1404− summary: "Tool call",
1405− payload: {
1406− itemType: "dynamic_tool_call",
1407− title: "Tool call",
1408− detail: 'Read: {"file_path":"/tmp/app.ts"}',
1409− },
1410− }),
1411− makeActivity({
1412− id: "tool-1-complete",
1413− createdAt: "2026-02-23T00:00:02.000Z",
1414− kind: "tool.completed",
1415− summary: "Tool call completed",
1416− payload: {
1417− itemType: "dynamic_tool_call",
1418− title: "Tool call",
1419− detail: 'Read: {"file_path":"/tmp/app.ts"}',
1420− },
1421− }),
1422− makeActivity({
1423− id: "tool-2-update",
1424− createdAt: "2026-02-23T00:00:03.000Z",
1425− kind: "tool.updated",
1426− summary: "Tool call",
1427− payload: {
1428− itemType: "dynamic_tool_call",
1429− title: "Tool call",
1430− detail: 'Read: {"file_path":"/tmp/app.ts"}',
1431− },
1432− }),
1433− makeActivity({
1434− id: "tool-2-complete",
1435− createdAt: "2026-02-23T00:00:04.000Z",
1436− kind: "tool.completed",
1437− summary: "Tool call completed",
1438− payload: {
1439− itemType: "dynamic_tool_call",
1440− title: "Tool call",
1441− detail: 'Read: {"file_path":"/tmp/app.ts"}',
1442− },
1443− }),
1444− ];
1445−
1446− const entries = deriveWorkLogEntries(activities);
1447−
1448− expect(entries.map((entry) => entry.id)).toEqual(["tool-1-complete", "tool-2-complete"]);
1449− });
1450−
1451− it("collapses same-timestamp lifecycle rows even when completed sorts before updated by id", () => {
1452− const activities: OrchestrationThreadActivity[] = [
1453− makeActivity({
1454− id: "z-update-earlier",
1455− createdAt: "2026-02-23T00:00:01.000Z",
1456− kind: "tool.updated",
1457− summary: "Tool call",
1458− payload: {
1459− itemType: "dynamic_tool_call",
1460− title: "Tool call",
1461− detail: 'Read: {"file_path":"/tmp/app.ts"}',
1462− },
1463− }),
1464− makeActivity({
1465− id: "a-complete-same-timestamp",
1466− createdAt: "2026-02-23T00:00:02.000Z",
1467− kind: "tool.completed",
1468− summary: "Tool call",
1469− payload: {
1470− itemType: "dynamic_tool_call",
1471− title: "Tool call",
1472− detail: 'Read: {"file_path":"/tmp/app.ts"}',
1473− },
1474− }),
1475− makeActivity({
1476− id: "z-update-same-timestamp",
1477− createdAt: "2026-02-23T00:00:02.000Z",
1478− kind: "tool.updated",
1479− summary: "Tool call",
1480− payload: {
1481− itemType: "dynamic_tool_call",
1482− title: "Tool call",
1483− detail: 'Read: {"file_path":"/tmp/app.ts"}',
1484− },
1485− }),
1486− ];
1487−
1488− const entries = deriveWorkLogEntries(activities);
1489−
1490− expect(entries).toHaveLength(1);
1491− expect(entries[0]?.id).toBe("a-complete-same-timestamp");
1492− });
1493−});
1494−
1495−describe("deriveTimelineEntries", () => {
1496− it("includes proposed plans alongside messages and work entries in chronological order", () => {
62+ it("orders conversation and generic V2 work entries", () => {
149763 const entries = deriveTimelineEntries(
149864 [
149965 {
1500− id: MessageId.make("message-1"),
1501− role: "assistant",
1502− text: "hello",
1503− createdAt: "2026-02-23T00:00:01.000Z",
1504− turnId: null,
1505− updatedAt: "2026-02-23T00:00:01.000Z",
66+ id: "message-1" as never,
67+ role: "user",
68+ text: "Hello",
69+ runId: null,
150670 streaming: false,
71+ createdAt: "2026-06-20T00:00:00.000Z",
72+ updatedAt: "2026-06-20T00:00:00.000Z",
150773 },
150874 ],
1509− [
1510− {
1511− id: "plan:thread-1:turn:turn-1",
1512− turnId: TurnId.make("turn-1"),
1513− planMarkdown: "# Ship it",
1514− implementedAt: null,
1515− implementationThreadId: null,
1516− createdAt: "2026-02-23T00:00:02.000Z",
1517− updatedAt: "2026-02-23T00:00:02.000Z",
1518− },
1519− ],
1520− [
1521− {
1522− id: "work-1",
1523− createdAt: "2026-02-23T00:00:03.000Z",
1524− label: "Ran tests",
1525− tone: "tool",
1526− },
1527− ],
75+ [],
76+ [],
152877 );
1529−
1530− expect(entries.map((entry) => entry.kind)).toEqual(["message", "proposed-plan", "work"]);
1531− expect(entries[1]).toMatchObject({
1532− kind: "proposed-plan",
1533− proposedPlan: {
1534− planMarkdown: "# Ship it",
1535− implementedAt: null,
1536− implementationThreadId: null,
1537− },
78+ expect(entries.map((entry) => entry.kind)).toEqual(["message"]);
79+ });
80+
81+ it("uses visible turn item order and keeps interruption lifecycle entries standalone", () => {
82+ const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
83+ const threadId = ThreadId.make("thread-visible");
84+ const runId = RunId.make("run-visible");
85+ const base = (id: string, ordinal: number) => ({
86+ id: TurnItemId.make(id),
87+ threadId,
88+ runId,
89+ nodeId: null,
90+ providerThreadId: null,
91+ providerTurnId: null,
92+ nativeItemRef: null,
93+ parentItemId: null,
94+ ordinal,
95+ status: "completed" as const,
96+ title: null,
97+ startedAt: now,
98+ completedAt: now,
99+ updatedAt: now,
100+ });
101+ const userItem = {
102+ ...base("item-user", 0),
103+ type: "user_message" as const,
104+ messageId: MessageId.make("message-user"),
105+ inputIntent: "turn_start" as const,
106+ text: "Start",
107+ attachments: [],
108+ createdBy: "user" as const,
109+ creationSource: "web" as const,
110+ } satisfies OrchestrationV2TurnItem;
111+ const requestItem = {
112+ ...base("item-interrupt-request", 1),
113+ type: "run_interrupt_request" as const,
114+ message: "Stopping",
115+ } satisfies OrchestrationV2TurnItem;
116+ const commandItem = {
117+ ...base("item-command", 2),
118+ type: "command_execution" as const,
119+ input: "sleep 1",
120+ output: "done",
121+ exitCode: 0,
122+ } satisfies OrchestrationV2TurnItem;
123+ const resultItem = {
124+ ...base("item-interrupt-result", 3),
125+ type: "run_interrupt_result" as const,
126+ message: "Stopped",
127+ } satisfies OrchestrationV2TurnItem;
128+ const visibleTurnItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem> = [
129+ userItem,
130+ requestItem,
131+ commandItem,
132+ resultItem,
133+ ].map((item, position) => ({
134+ position,
135+ visibility: "local" as const,
136+ sourceThreadId: threadId,
137+ sourceItemId: item.id,
138+ item,
139+ }));
140+
141+ const entries = deriveTimelineEntriesFromVisibleTurnItems({
142+ visibleTurnItems,
143+ optimisticMessages: [],
1538144 });
1539− });
1540−});
1541−
1542−describe("deriveWorkLogEntries context window handling", () => {
1543− it("excludes context window updates from the work log", () => {
1544− const entries = deriveWorkLogEntries([
1545− makeActivity({
1546− id: "context-1",
1547− turnId: "turn-1",
1548− kind: "context-window.updated",
1549− summary: "Context window updated",
1550− tone: "info",
1551− }),
1552− makeActivity({
1553− id: "tool-1",
1554− turnId: "turn-1",
1555− kind: "tool.completed",
1556− summary: "Ran command",
1557− tone: "tool",
1558− }),
1559− ]);
1560−
1561− expect(entries).toHaveLength(1);
1562− expect(entries[0]?.label).toBe("Ran command");
1563− });
1564145
1565− it("keeps context compaction activities as normal work log entries", () => {
1566− const entries = deriveWorkLogEntries([
1567− makeActivity({
1568− id: "compaction-1",
1569− turnId: "turn-1",
1570− kind: "context-compaction",
1571− summary: "Context compacted",
1572− tone: "info",
1573− }),
146+ expect(entries.map((entry) => [entry.kind, entry.id])).toEqual([
147+ ["message", userItem.messageId],
148+ ["event", requestItem.id],
149+ ["work", commandItem.id],
150+ ["event", resultItem.id],
1574151 ]);
1575−
1576− expect(entries).toHaveLength(1);
1577− expect(entries[0]?.label).toBe("Context compacted");
1578− });
1579−});
1580−
1581−describe("isLatestTurnSettled", () => {
1582− const latestTurn = {
1583− turnId: TurnId.make("turn-1"),
1584− startedAt: "2026-02-27T21:10:00.000Z",
1585− completedAt: "2026-02-27T21:10:06.000Z",
1586− } as const;
1587−
1588− it("returns false while the same turn is still active in a running session", () => {
1589− expect(
1590− isLatestTurnSettled(latestTurn, {
1591− status: "running",
1592− activeTurnId: TurnId.make("turn-1"),
1593− }),
1594− ).toBe(false);
1595− });
1596−
1597− it("returns false while any turn is running to avoid stale latest-turn banners", () => {
1598− expect(
1599− isLatestTurnSettled(latestTurn, {
1600− status: "running",
1601− activeTurnId: TurnId.make("turn-2"),
1602− }),
1603− ).toBe(false);
1604− });
1605−
1606− it("returns true once the session is no longer running that turn", () => {
1607− expect(
1608− isLatestTurnSettled(latestTurn, {
1609− status: "ready",
1610− activeTurnId: null,
1611− }),
1612− ).toBe(true);
1613− });
1614−
1615− it("returns false when turn timestamps are incomplete", () => {
1616− expect(
1617− isLatestTurnSettled(
1618− {
1619− turnId: TurnId.make("turn-1"),
1620− startedAt: null,
1621− completedAt: "2026-02-27T21:10:06.000Z",
1622− },
1623− null,
1624− ),
1625− ).toBe(false);
1626− });
1627−});
1628−
1629−describe("deriveActiveWorkStartedAt", () => {
1630− const latestTurn = {
1631− turnId: TurnId.make("turn-1"),
1632− startedAt: "2026-02-27T21:10:00.000Z",
1633− completedAt: "2026-02-27T21:10:06.000Z",
1634− } as const;
1635−
1636− it("prefers the in-flight turn start when the latest turn is not settled", () => {
1637− expect(
1638− deriveActiveWorkStartedAt(
1639− latestTurn,
1640− {
1641− status: "running",
1642− activeTurnId: TurnId.make("turn-1"),
1643− },
1644− "2026-02-27T21:11:00.000Z",
1645− ),
1646− ).toBe("2026-02-27T21:10:00.000Z");
1647− });
1648−
1649− it("uses the new send start while the session is running a different turn", () => {
1650− expect(
1651− deriveActiveWorkStartedAt(
1652− latestTurn,
1653− {
1654− status: "running",
1655− activeTurnId: TurnId.make("turn-2"),
1656− },
1657− "2026-02-27T21:11:00.000Z",
1658− ),
1659− ).toBe("2026-02-27T21:11:00.000Z");
1660− });
1661−
1662− it("falls back to sendStartedAt once the latest turn is settled", () => {
1663− expect(
1664− deriveActiveWorkStartedAt(
1665− latestTurn,
1666− {
1667− status: "ready",
1668− activeTurnId: null,
1669− },
1670− "2026-02-27T21:11:00.000Z",
1671− ),
1672− ).toBe("2026-02-27T21:11:00.000Z");
1673− });
1674−
1675− it("uses sendStartedAt for a fresh send after the prior turn completed", () => {
1676− expect(
1677− deriveActiveWorkStartedAt(
1678− {
1679− turnId: TurnId.make("turn-1"),
1680− startedAt: "2026-02-27T21:10:00.000Z",
1681− completedAt: "2026-02-27T21:10:06.000Z",
1682− },
1683− null,
1684− "2026-02-27T21:11:00.000Z",
1685− ),
1686− ).toBe("2026-02-27T21:11:00.000Z");
152+ const commandEntry = entries[2];
153+ expect(commandEntry?.kind).toBe("work");
154+ if (commandEntry?.kind === "work") {
155+ expect(commandEntry.entry.projectedItem).toBe(visibleTurnItems[2]);
156+ expect(commandEntry.entry.structuredPayload).toBe(commandItem);
157+ }
1687158 });
1688159 });
apps/web/src/session-logic.ts+392−1244
@@ -1,26 +1,24 @@
1−import * as Option from "effect/Option";
2−import * as Arr from "effect/Array";
31 import {
4− ApprovalRequestId,
5− isToolLifecycleItemType,
6− type OrchestrationLatestTurn,
7− type OrchestrationThreadActivity,
8− type OrchestrationProposedPlanId,
92 ProviderDriverKind,
10− type ToolLifecycleItemType,
11− type UserInputQuestion,
3+ type OrchestrationV2ProjectedTurnItem,
4+ type OrchestrationV2TurnItem,
5+ type PlanId,
6+ type RunId,
127 type ThreadId,
13− type TurnId,
148 } from "@t3tools/contracts";
15−
169 import type {
17− ChatMessage,
18− ProposedPlan,
19− SessionPhase,
20− Thread,
21− ThreadSession,
22− TurnDiffSummary,
23−} from "./types";
10+ ThreadCheckpointSummary,
11+ ThreadPendingApproval,
12+ ThreadPendingUserInput,
13+ ThreadProposedPlan,
14+ ThreadRunSummary,
15+ ThreadRuntimeSummary,
16+ ThreadTodoPlan,
17+ ThreadWorkEntry,
18+} from "@t3tools/client-runtime/state/shell";
19+
20+import type { ChatMessage, ProposedPlan, SessionPhase, TurnDiffSummary } from "./types";
21+import * as DateTime from "effect/DateTime";
2422
2523 export type ProviderPickerKind = ProviderDriverKind;
2624
@@ -28,7 +26,6 @@ export const PROVIDER_OPTIONS: Array<{ · 1 unmodified line
2826 value: ProviderPickerKind;
2927 label: string;
3028 available: boolean;
31− /** Shown on the model picker sidebar when relevant */
3229 pickerSidebarBadge?: "new" | "soon";
3330 }> = [
3431 { value: ProviderDriverKind.make("codex"), label: "Codex", available: true },
@@ -45,221 +42,101 @@ export const PROVIDER_OPTIONS: Array<{ · 10 unmodified lines
4542 available: true,
4643 pickerSidebarBadge: "new",
4744 },
48− {
49− value: ProviderDriverKind.make("grok"),
50− label: "Grok",
51− available: true,
52− pickerSidebarBadge: "new",
53− },
45+ { value: ProviderDriverKind.make("grok"), label: "Grok", available: true },
5446 ];
5547
56−export type WorkLogToolLifecycleStatus =
57− | "inProgress"
58− | "completed"
59− | "failed"
60− | "declined"
61− | "stopped";
62−
63−export interface WorkLogEntry {
64− id: string;
65− createdAt: string;
66− turnId?: TurnId | null;
67− label: string;
68− detail?: string;
69− command?: string;
70− rawCommand?: string;
71− changedFiles?: ReadonlyArray<string>;
72− tone: "thinking" | "tool" | "info" | "error";
73− toolTitle?: string;
74− toolData?: unknown;
75− itemType?: ToolLifecycleItemType;
76− requestKind?: PendingApproval["requestKind"];
77− /** From runtime item / task payload `status` when present (e.g. tool.updated). */
78− toolLifecycleStatus?: WorkLogToolLifecycleStatus;
79− /** Originating orchestration activity kind (e.g. `user-input.requested`) for row chrome. */
80− sourceActivityKind?: OrchestrationThreadActivity["kind"];
81−}
82−
83−interface DerivedWorkLogEntry extends WorkLogEntry {
84− activityKind: OrchestrationThreadActivity["kind"];
85− collapseKey?: string;
86− toolCallId?: string;
87−}
48+export type WorkLogToolLifecycleStatus = ThreadWorkEntry["toolLifecycleStatus"];
8849
89−export interface PendingApproval {
90− requestId: ApprovalRequestId;
91− requestKind: "command" | "file-read" | "file-change";
92− createdAt: string;
93− detail?: string;
50+export interface WorkLogEntry extends Omit<
51+ ThreadWorkEntry,
52+ "structuredPayload" | "runId" | "itemType" | "toolLifecycleStatus"
53+> {
54+ readonly runId?: RunId | null;
55+ readonly itemType?: ThreadWorkEntry["itemType"];
56+ readonly toolLifecycleStatus?: ThreadWorkEntry["toolLifecycleStatus"];
57+ readonly structuredPayload?: ThreadWorkEntry["structuredPayload"];
58+ readonly sourceItemType?: ThreadWorkEntry["itemType"];
59+ readonly projectedItem?: OrchestrationV2ProjectedTurnItem;
9460 }
9561
96−export interface PendingUserInput {
97− requestId: ApprovalRequestId;
98− createdAt: string;
99− questions: ReadonlyArray<UserInputQuestion>;
100−}
62+export type PendingApproval = ThreadPendingApproval;
63+export type PendingUserInput = ThreadPendingUserInput;
10164
10265 export interface ActivePlanState {
103− createdAt: string;
104− turnId: TurnId | null;
105− explanation?: string | null;
106− steps: Array<{
107− step: string;
108− status: "pending" | "inProgress" | "completed";
66+ readonly createdAt: string;
67+ readonly runId: RunId | null;
68+ readonly explanation?: string | null;
69+ readonly steps: Array<{
70+ readonly step: string;
71+ readonly status: "pending" | "inProgress" | "completed";
10972 }>;
11073 }
11174
11275 export interface LatestProposedPlanState {
113− id: OrchestrationProposedPlanId;
114− createdAt: string;
115− updatedAt: string;
116− turnId: TurnId | null;
117− planMarkdown: string;
118− implementedAt: string | null;
119− implementationThreadId: ThreadId | null;
76+ readonly id: PlanId;
77+ readonly createdAt: string;
78+ readonly updatedAt: string;
79+ readonly runId: RunId | null;
80+ readonly planMarkdown: string;
81+ readonly implementedAt: string | null;
82+ readonly implementationThreadId: ThreadId | null;
83+ readonly status: ThreadProposedPlan["status"];
12084 }
12185
12286 export type TimelineEntry =
12387 | {
124− id: string;
125− kind: "message";
126− createdAt: string;
127− message: ChatMessage;
88+ readonly id: string;
89+ readonly kind: "message";
90+ readonly createdAt: string;
91+ readonly message: ChatMessage;
92+ }
93+ | {
94+ readonly id: string;
95+ readonly kind: "proposed-plan";
96+ readonly createdAt: string;
97+ readonly proposedPlan: ProposedPlan;
12898 }
12999 | {
130− id: string;
131− kind: "proposed-plan";
132− createdAt: string;
133− proposedPlan: ProposedPlan;
100+ readonly id: string;
101+ readonly kind: "work";
102+ readonly createdAt: string;
103+ readonly entry: WorkLogEntry;
134104 }
135105 | {
136− id: string;
137− kind: "work";
138− createdAt: string;
139− entry: WorkLogEntry;
106+ readonly id: string;
107+ readonly kind: "event";
108+ readonly createdAt: string;
109+ readonly projectedItem: OrchestrationV2ProjectedTurnItem;
140110 };
141111
142112 export function workLogEntryIsToolLike(entry: WorkLogEntry): boolean {
143− if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") {
144− return true;
145− }
146− if (entry.command !== undefined && entry.command.trim().length > 0) {
147− return true;
148− }
149− if (entry.requestKind !== undefined) {
150− return true;
151− }
152− return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType);
153−}
154−
155−/** Heuristic: providers often emit successful lifecycle status while error text lives in `detail` / `command`. */
156−function toolDetailTextLooksLikeFailure(text: string): boolean {
157− const t = text.toLowerCase();
158− if (t.includes("file not found")) {
159− return true;
160− }
161− if (t.includes("no files found")) {
162− return true;
163− }
164− if (
165− t.includes("enoent") ||
166− t.includes("no such file or directory") ||
167− t.includes("no such file")
168− ) {
169− return true;
170− }
171− if (t.includes("cannot find path") && t.includes("because it does not exist")) {
172− return true;
173− }
174− if (t.includes("commandnotfoundexception")) {
175− return true;
176− }
177− if (t.includes("is not recognized as the name of a cmdlet")) {
178− return true;
179− }
180− if (t.includes("is not recognized") && t.includes("the term '")) {
181− return true;
182− }
183− if (t.includes("a parameter cannot be found that matches parameter name")) {
184− return true;
185− }
186− if (t.includes("command not found")) {
187− return true;
188− }
189− if (/<exited with exit code\s+[1-9]\d*\s*>/i.test(text)) {
190− return true;
191− }
192− if (/exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text)) {
193− return true;
194− }
195− if (/exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text)) {
196− return true;
197− }
198− return false;
113+ return (
114+ entry.tone === "tool" ||
115+ entry.tone === "thinking" ||
116+ entry.tone === "error" ||
117+ entry.command !== undefined ||
118+ entry.requestKind !== undefined
119+ );
199120 }
200121
201−/** True when the row should show a failure affordance (explicit status/tone or error-shaped tool output). */
202122 export function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean {
203− if (entry.tone === "error") {
204− return true;
205− }
206− const ls = entry.toolLifecycleStatus;
207− if (ls === "failed" || ls === "declined") {
208− return true;
209− }
210− if (!workLogEntryIsToolLike(entry)) {
211− return false;
212− }
213− const parts: string[] = [];
214− if (entry.detail) {
215− parts.push(entry.detail);
216− }
217− if (entry.command) {
218− parts.push(entry.command);
219− }
220− const blob = parts.join("\n");
221− if (blob.length === 0) {
222− return false;
223− }
224− return toolDetailTextLooksLikeFailure(blob);
123+ return (
124+ entry.tone === "error" ||
125+ entry.toolLifecycleStatus === "failed" ||
126+ entry.toolLifecycleStatus === "declined"
127+ );
225128 }
226129
227−/** Tool/command row completed without failure (blue check affordance). */
228130 export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean {
229− if (!workLogEntryIsToolLike(entry)) {
230− return false;
231− }
232− if (workEntryIndicatesToolFailure(entry)) {
233− return false;
234− }
235− if (entry.tone === "thinking") {
236− return false;
237− }
238− const ls = entry.toolLifecycleStatus;
239− if (ls === "failed" || ls === "declined") {
240− return false;
241− }
242− if (ls === "inProgress") {
243− return false;
244− }
245− if (ls === "stopped") {
246− return false;
247− }
248− return true;
131+ return workLogEntryIsToolLike(entry) && entry.toolLifecycleStatus === "completed";
249132 }
250133
251−/** Tool-like row with neither clear success nor failure (empty, incomplete, in progress, etc.). */
252134 export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolean {
253− if (!workLogEntryIsToolLike(entry)) {
254− return false;
255− }
256− if (workEntryIndicatesToolFailure(entry)) {
257− return false;
258− }
259− if (workEntryIndicatesToolSuccess(entry)) {
260− return false;
261− }
262− return true;
135+ return (
136+ workLogEntryIsToolLike(entry) &&
137+ !workEntryIndicatesToolFailure(entry) &&
138+ !workEntryIndicatesToolSuccess(entry)
139+ );
263140 }
264141
265142 export function formatDuration(durationMs: number): string {
@@ -267,7 +144,6 @@ export function formatDuration(durationMs: number): string { · 1 unmodified line
267144 if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`;
268145 if (durationMs < 10_000) {
269146 const tenths = Math.round(durationMs / 100) / 10;
270− // 9.95s+ rounds up to the next bucket — render "10s", not "10.0s".
271147 return tenths >= 10 ? "10s" : `${tenths.toFixed(1)}s`;
272148 }
273149 if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`;
@@ -282,1112 +158,384 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str · 8 unmodified lines
282158 if (!endIso) return null;
283159 const startedAt = Date.parse(startIso);
284160 const endedAt = Date.parse(endIso);
285− if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) {
286− return null;
287− }
161+ if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) return null;
288162 return formatDuration(endedAt - startedAt);
289163 }
290164
291−type LatestTurnTiming = Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt">;
292−type SessionActivityState = Pick<NonNullable<Thread["session"]>, "status" | "activeTurnId">;
293−
294−export function isLatestTurnSettled(
295− latestTurn: LatestTurnTiming | null,
296− session: SessionActivityState | null,
165+export function isLatestRunSettled(
166+ latestRun: Pick<ThreadRunSummary, "runId" | "startedAt" | "completedAt" | "status"> | null,
167+ runtime: Pick<ThreadRuntimeSummary, "status" | "activeRunId"> | null,
297168 ): boolean {
298− if (!latestTurn?.startedAt) return false;
299− if (!latestTurn.completedAt) return false;
300− if (!session) return true;
301− if (session.status === "running") return false;
302− return true;
169+ if (latestRun === null) return false;
170+ if (
171+ latestRun.status === "starting" ||
172+ latestRun.status === "running" ||
173+ latestRun.status === "waiting"
174+ )
175+ return false;
176+ return runtime?.activeRunId !== latestRun.runId;
303177 }
304178
305179 export function deriveActiveWorkStartedAt(
306− latestTurn: LatestTurnTiming | null,
307− session: SessionActivityState | null,
180+ latestRun: Pick<ThreadRunSummary, "runId" | "startedAt" | "completedAt" | "status"> | null,
181+ runtime: Pick<ThreadRuntimeSummary, "status" | "activeRunId"> | null,
308182 sendStartedAt: string | null,
309183 ): string | null {
310− const runningTurnId = session?.status === "running" ? session.activeTurnId : null;
311− if (runningTurnId !== null) {
312− if (latestTurn?.turnId === runningTurnId) {
313− return latestTurn.startedAt ?? sendStartedAt;
314− }
315− return sendStartedAt;
184+ if (runtime?.activeRunId !== null && runtime?.activeRunId !== undefined) {
185+ return latestRun?.runId === runtime.activeRunId
186+ ? (latestRun.startedAt ?? sendStartedAt)
187+ : sendStartedAt;
316188 }
317− if (!isLatestTurnSettled(latestTurn, session)) {
318− return latestTurn?.startedAt ?? sendStartedAt;
319− }
320− return sendStartedAt;
321−}
322−
323−function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null {
324− switch (requestType) {
325− case "command_execution_approval":
326− case "exec_command_approval":
327− case "dynamic_tool_call":
328− return "command";
329− case "file_read_approval":
330− return "file-read";
331− case "file_change_approval":
332− case "apply_patch_approval":
333− return "file-change";
334− default:
335− return null;
336− }
337−}
338−
339−function isStalePendingRequestFailureDetail(detail: string | undefined): boolean {
340− const normalized = detail?.toLowerCase();
341− if (!normalized) {
342− return false;
343− }
344− return (
345− normalized.includes("stale pending approval request") ||
346− normalized.includes("stale pending user-input request") ||
347− normalized.includes("unknown pending approval request") ||
348− normalized.includes("unknown pending permission request") ||
349− normalized.includes("unknown pending user-input request") ||
350− normalized.includes("unknown pending user input request") ||
351− normalized.includes("unknown pending codex user input request")
352− );
189+ return isLatestRunSettled(latestRun, runtime)
190+ ? sendStartedAt
191+ : (latestRun?.startedAt ?? sendStartedAt);
353192 }
354193
355194 export function derivePendingApprovals(
356− activities: ReadonlyArray<OrchestrationThreadActivity>,
357−): PendingApproval[] {
358− const openByRequestId = new Map<ApprovalRequestId, PendingApproval>();
359− const ordered = [...activities].toSorted(compareActivitiesByOrder);
360−
361− for (const activity of ordered) {
362− const payload =
363− activity.payload && typeof activity.payload === "object"
364− ? (activity.payload as Record<string, unknown>)
365− : null;
366− const requestId =
367− payload && typeof payload.requestId === "string"
368− ? ApprovalRequestId.make(payload.requestId)
369− : null;
370− const requestKind =
371− payload &&
372− (payload.requestKind === "command" ||
373− payload.requestKind === "file-read" ||
374− payload.requestKind === "file-change")
375− ? payload.requestKind
376− : payload
377− ? requestKindFromRequestType(payload.requestType)
378− : null;
379− const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined;
380−
381− if (activity.kind === "approval.requested" && requestId && requestKind) {
382− openByRequestId.set(requestId, {
383− requestId,
384− requestKind,
385− createdAt: activity.createdAt,
386− ...(detail ? { detail } : {}),
387− });
388− continue;
389− }
390−
391− if (activity.kind === "approval.resolved" && requestId) {
392− openByRequestId.delete(requestId);
393− continue;
394− }
395−
396− if (
397− activity.kind === "provider.approval.respond.failed" &&
398− requestId &&
399− isStalePendingRequestFailureDetail(detail)
400− ) {
401− openByRequestId.delete(requestId);
402− continue;
403− }
404− }
405−
406− return [...openByRequestId.values()].toSorted((left, right) =>
407− left.createdAt.localeCompare(right.createdAt),
408− );
409−}
410−
411−function parseUserInputQuestions(
412− payload: Record<string, unknown> | null,
413−): ReadonlyArray<UserInputQuestion> | null {
414− const questions = payload?.questions;
415− if (!Array.isArray(questions)) {
416− return null;
417− }
418− const parsed = questions
419− .map<UserInputQuestion | null>((entry) => {
420− if (!entry || typeof entry !== "object") return null;
421− const question = entry as Record<string, unknown>;
422− if (
423− typeof question.id !== "string" ||
424− typeof question.header !== "string" ||
425− typeof question.question !== "string" ||
426− !Array.isArray(question.options)
427− ) {
428− return null;
429− }
430− const options = question.options
431− .map<UserInputQuestion["options"][number] | null>((option) => {
432− if (!option || typeof option !== "object") return null;
433− const optionRecord = option as Record<string, unknown>;
434− if (
435− typeof optionRecord.label !== "string" ||
436− typeof optionRecord.description !== "string"
437− ) {
438− return null;
439− }
440− return {
441− label: optionRecord.label,
442− description: optionRecord.description,
443− };
444− })
445− .filter((option): option is UserInputQuestion["options"][number] => option !== null);
446− if (options.length === 0) {
447− return null;
448− }
449− return {
450− id: question.id,
451− header: question.header,
452− question: question.question,
453− options,
454− multiSelect: question.multiSelect === true,
455− };
456− })
457− .filter((question): question is UserInputQuestion => question !== null);
458− return parsed.length > 0 ? parsed : null;
195+ approvals: ReadonlyArray<ThreadPendingApproval>,
196+): ThreadPendingApproval[] {
197+ return [...approvals].toSorted((left, right) => left.createdAt.localeCompare(right.createdAt));
459198 }
460199
461200 export function derivePendingUserInputs(
462− activities: ReadonlyArray<OrchestrationThreadActivity>,
463−): PendingUserInput[] {
464− const openByRequestId = new Map<ApprovalRequestId, PendingUserInput>();
465− const ordered = [...activities].toSorted(compareActivitiesByOrder);
466−
467− for (const activity of ordered) {
468− const payload =
469− activity.payload && typeof activity.payload === "object"
470− ? (activity.payload as Record<string, unknown>)
471− : null;
472− const requestId =
473− payload && typeof payload.requestId === "string"
474− ? ApprovalRequestId.make(payload.requestId)
475− : null;
476− const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined;
477−
478− if (activity.kind === "user-input.requested" && requestId) {
479− const questions = parseUserInputQuestions(payload);
480− if (!questions) {
481− continue;
482− }
483− openByRequestId.set(requestId, {
484− requestId,
485− createdAt: activity.createdAt,
486− questions,
487− });
488− continue;
489− }
490−
491− if (activity.kind === "user-input.resolved" && requestId) {
492− openByRequestId.delete(requestId);
493− continue;
494− }
495−
496− if (
497− activity.kind === "provider.user-input.respond.failed" &&
498− requestId &&
499− isStalePendingRequestFailureDetail(detail)
500− ) {
501− openByRequestId.delete(requestId);
502− }
503− }
504−
505− return [...openByRequestId.values()].toSorted((left, right) =>
506− left.createdAt.localeCompare(right.createdAt),
507− );
201+ inputs: ReadonlyArray<ThreadPendingUserInput>,
202+): ThreadPendingUserInput[] {
203+ return [...inputs].toSorted((left, right) => left.createdAt.localeCompare(right.createdAt));
508204 }
509205
510206 export function deriveActivePlanState(
511− activities: ReadonlyArray<OrchestrationThreadActivity>,
512− latestTurnId: TurnId | undefined,
207+ plans: ReadonlyArray<ThreadTodoPlan>,
208+ latestRunId: RunId | undefined,
513209 ): ActivePlanState | null {
514− const ordered = [...activities].toSorted(compareActivitiesByOrder);
515− const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated");
516− // Prefer plan from the current turn; fall back to the most recent plan from any turn
517− // so that TodoWrite tasks persist across follow-up messages.
518− const latest = Option.firstSomeOf([
519− ...(latestTurnId
520− ? Arr.findLast(allPlanActivities, (activity) => activity.turnId === latestTurnId)
521− : Option.none()),
522− Arr.last(allPlanActivities),
523− ]).pipe(Option.getOrNull);
524− if (!latest) {
525− return null;
526− }
527− const payload =
528− latest.payload && typeof latest.payload === "object"
529− ? (latest.payload as Record<string, unknown>)
530− : null;
531− const rawPlan = payload?.plan;
532− if (!Array.isArray(rawPlan)) {
533− return null;
534− }
535− const steps: Array<{
536− step: string;
537− status: "pending" | "inProgress" | "completed";
538− }> = [];
539− for (const entry of rawPlan) {
540− if (!entry || typeof entry !== "object") {
541− continue;
542− }
543− const record = entry as Record<string, unknown>;
544− if (typeof record.step !== "string") {
545− continue;
546− }
547− const status =
548− record.status === "completed" || record.status === "inProgress" ? record.status : "pending";
549− steps.push({
550− step: record.step,
551− status,
552− });
553− }
554− if (steps.length === 0) {
555− return null;
556− }
210+ const plan =
211+ [...plans].toReversed().find((candidate) => candidate.runId === latestRunId) ??
212+ plans.at(-1) ??
213+ null;
214+ if (plan === null || plan.steps.length === 0) return null;
215+ return {
216+ createdAt: plan.updatedAt,
217+ runId: plan.runId,
218+ explanation: plan.explanation,
219+ steps: plan.steps.map(({ step, status }) => ({ step, status })),
220+ };
221+}
222+
223+function toLatestProposedPlanState(plan: ThreadProposedPlan): LatestProposedPlanState {
557224 return {
558− createdAt: latest.createdAt,
559− turnId: latest.turnId,
560− ...(payload && "explanation" in payload
561− ? { explanation: payload.explanation as string | null }
562− : {}),
563− steps,
225+ id: plan.id,
226+ createdAt: plan.createdAt,
227+ updatedAt: plan.updatedAt,
228+ runId: plan.runId,
229+ planMarkdown: plan.planMarkdown,
230+ implementedAt: plan.implementedAt,
231+ implementationThreadId: plan.implementationThreadId,
232+ status: plan.status,
564233 };
565234 }
566235
567236 export function findLatestProposedPlan(
568− proposedPlans: ReadonlyArray<ProposedPlan>,
569− latestTurnId: TurnId | string | null | undefined,
237+ plans: ReadonlyArray<ThreadProposedPlan>,
238+ latestRunId: RunId | string | null | undefined,
570239 ): LatestProposedPlanState | null {
571− if (latestTurnId) {
572− const matchingTurnPlan = [...proposedPlans]
573− .filter((proposedPlan) => proposedPlan.turnId === latestTurnId)
574− .toSorted(
575− (left, right) =>
576− left.updatedAt.localeCompare(right.updatedAt) || left.id.localeCompare(right.id),
577− )
578− .at(-1);
579− if (matchingTurnPlan) {
580− return toLatestProposedPlanState(matchingTurnPlan);
581− }
582− }
583−
584− const latestPlan = [...proposedPlans]
240+ const candidates = latestRunId ? plans.filter((plan) => plan.runId === latestRunId) : plans;
241+ const plan = [...(candidates.length > 0 ? candidates : plans)]
585242 .toSorted(
586243 (left, right) =>
587244 left.updatedAt.localeCompare(right.updatedAt) || left.id.localeCompare(right.id),
588245 )
589246 .at(-1);
590− if (!latestPlan) {
591− return null;
592− }
593−
594− return toLatestProposedPlanState(latestPlan);
247+ return plan === undefined ? null : toLatestProposedPlanState(plan);
595248 }
596249
597250 export function findSidebarProposedPlan(input: {
598− threads: ReadonlyArray<Pick<Thread, "id" | "proposedPlans">>;
599− latestTurn: Pick<OrchestrationLatestTurn, "turnId" | "sourceProposedPlan"> | null;
600− latestTurnSettled: boolean;
601− threadId: ThreadId | string | null | undefined;
251+ readonly threads: ReadonlyArray<
252+ Pick<
253+ { readonly id: ThreadId; readonly proposedPlans: ReadonlyArray<ThreadProposedPlan> },
254+ "id" | "proposedPlans"
255+ >
256+ >;
257+ readonly latestRun: Pick<ThreadRunSummary, "runId" | "sourcePlanRef"> | null;
258+ readonly latestRunSettled: boolean;
259+ readonly threadId: ThreadId | string | null | undefined;
602260 }): LatestProposedPlanState | null {
603− const activeThreadPlans =
261+ if (!input.latestRunSettled && input.latestRun?.sourcePlanRef !== undefined) {
262+ const source = input.latestRun.sourcePlanRef;
263+ const plan = input.threads
264+ .find((thread) => thread.id === source.threadId)
265+ ?.proposedPlans.find((candidate) => candidate.id === source.planId);
266+ if (plan !== undefined) return toLatestProposedPlanState(plan);
267+ }
268+ const activePlans =
604269 input.threads.find((thread) => thread.id === input.threadId)?.proposedPlans ?? [];
605−
606− if (!input.latestTurnSettled) {
607− const sourceProposedPlan = input.latestTurn?.sourceProposedPlan;
608− if (sourceProposedPlan) {
609− const sourcePlan = input.threads
610− .find((thread) => thread.id === sourceProposedPlan.threadId)
611− ?.proposedPlans.find((plan) => plan.id === sourceProposedPlan.planId);
612− if (sourcePlan) {
613− return toLatestProposedPlanState(sourcePlan);
614− }
615− }
616− }
617−
618− return findLatestProposedPlan(activeThreadPlans, input.latestTurn?.turnId ?? null);
270+ return findLatestProposedPlan(activePlans, input.latestRun?.runId);
619271 }
620272
621273 export function hasActionableProposedPlan(
622− proposedPlan: LatestProposedPlanState | Pick<ProposedPlan, "implementedAt"> | null,
623−): boolean {
624− return proposedPlan !== null && proposedPlan.implementedAt === null;
625−}
626−
627−export function deriveWorkLogEntries(
628− activities: ReadonlyArray<OrchestrationThreadActivity>,
629−): WorkLogEntry[] {
630− const ordered = [...activities].toSorted(compareActivitiesByOrder);
631− const entries: DerivedWorkLogEntry[] = [];
632− for (const activity of ordered) {
633− if (activity.kind === "tool.started") continue;
634− if (activity.kind === "task.started") continue;
635− if (activity.kind === "context-window.updated") continue;
636− if (activity.summary === "Checkpoint captured") continue;
637− if (isPlanBoundaryToolActivity(activity)) continue;
638− entries.push(toDerivedWorkLogEntry(activity));
639− }
640− return collapseDerivedWorkLogEntries(entries).map((entry) => {
641− const { activityKind, collapseKey: _collapseKey, ...rest } = entry;
642− return Object.assign(rest, { sourceActivityKind: activityKind });
643− });
644−}
645−
646−function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
647− if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {
648− return false;
649− }
650−
651− const payload =
652− activity.payload && typeof activity.payload === "object"
653− ? (activity.payload as Record<string, unknown>)
654− : null;
655− return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");
656−}
657−
658−function extractWorkLogToolLifecycleStatus(
659− payload: Record<string, unknown> | null,
660−): WorkLogToolLifecycleStatus | undefined {
661− if (!payload) {
662− return undefined;
663− }
664− const s = payload.status;
665− if (
666− s === "inProgress" ||
667− s === "completed" ||
668− s === "failed" ||
669− s === "declined" ||
670− s === "stopped"
671− ) {
672− return s;
673− }
674− return undefined;
675−}
676−
677−function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry {
678− const payload =
679− activity.payload && typeof activity.payload === "object"
680− ? (activity.payload as Record<string, unknown>)
681− : null;
682− const commandPreview = extractToolCommand(payload);
683− const changedFiles = extractChangedFiles(payload);
684− const title = extractToolTitle(payload);
685− const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed";
686− const taskSummary =
687− isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0
688− ? payload.summary
689− : null;
690− const taskDetailAsLabel =
691− isTaskActivity &&
692− !taskSummary &&
693− typeof payload?.detail === "string" &&
694− payload.detail.length > 0
695− ? payload.detail
696− : null;
697− const taskLabel = taskSummary || taskDetailAsLabel;
698− const detail = isTaskActivity
699− ? !taskDetailAsLabel &&
700− payload &&
701− typeof payload.detail === "string" &&
702− payload.detail.length > 0
703− ? stripTrailingExitCode(payload.detail).output
704− : null
705− : extractToolDetail(payload, title ?? activity.summary);
706− const toolCallId = isTaskActivity ? null : extractToolCallId(payload);
707− const entry: DerivedWorkLogEntry = {
708− id: activity.id,
709− createdAt: activity.createdAt,
710− turnId: activity.turnId,
711− label: taskLabel || activity.summary,
712− tone:
713− activity.kind === "task.progress"
714− ? "thinking"
715− : activity.tone === "approval"
716− ? "info"
717− : activity.tone,
718− activityKind: activity.kind,
719− };
720− const itemType = extractWorkLogItemType(payload);
721− const requestKind = extractWorkLogRequestKind(payload);
722− if (detail) {
723− entry.detail = detail;
724− }
725− if (commandPreview.command) {
726− entry.command = commandPreview.command;
727− }
728− if (commandPreview.rawCommand) {
729− entry.rawCommand = commandPreview.rawCommand;
730− }
731− if (changedFiles.length > 0) {
732− entry.changedFiles = changedFiles;
733− }
734− if (title) {
735− entry.toolTitle = title;
736− }
737− if (itemType === "mcp_tool_call") {
738− const data = asRecord(payload?.data);
739− if (data?.item !== undefined) {
740− entry.toolData = data.item;
741− }
742− }
743− if (itemType) {
744− entry.itemType = itemType;
745− }
746− if (requestKind) {
747− entry.requestKind = requestKind;
748− }
749− if (toolCallId) {
750− entry.toolCallId = toolCallId;
751− }
752− let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload);
753− if (!toolLifecycleStatus && activity.kind === "tool.completed") {
754− toolLifecycleStatus = "completed";
755− }
756− if (toolLifecycleStatus) {
757− entry.toolLifecycleStatus = toolLifecycleStatus;
758− }
759− const collapseKey = deriveToolLifecycleCollapseKey(entry);
760− if (collapseKey) {
761− entry.collapseKey = collapseKey;
762− }
763− return entry;
764−}
765−
766−function collapseDerivedWorkLogEntries(
767− entries: ReadonlyArray<DerivedWorkLogEntry>,
768−): DerivedWorkLogEntry[] {
769− const collapsed: DerivedWorkLogEntry[] = [];
770− for (const entry of entries) {
771− const previous = collapsed.at(-1);
772− if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {
773− collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry);
774− continue;
775− }
776− collapsed.push(entry);
777− }
778− return collapsed;
779−}
780−
781−function shouldCollapseToolLifecycleEntries(
782− previous: DerivedWorkLogEntry,
783− next: DerivedWorkLogEntry,
274+ plan: LatestProposedPlanState | Pick<ThreadProposedPlan, "implementedAt"> | null,
784275 ): boolean {
785− if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") {
786− return false;
787− }
788− if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") {
789− return false;
790− }
791− if (previous.activityKind === "tool.completed") {
792− return false;
793− }
794− if (previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey) {
795− return true;
796− }
797− return (
798− previous.toolCallId !== undefined &&
799− next.toolCallId === undefined &&
800− previous.itemType === next.itemType &&
801− normalizeCompactToolLabel(previous.toolTitle ?? previous.label) ===
802− normalizeCompactToolLabel(next.toolTitle ?? next.label)
803− );
804−}
805−
806−function mergeDerivedWorkLogEntries(
807− previous: DerivedWorkLogEntry,
808− next: DerivedWorkLogEntry,
809−): DerivedWorkLogEntry {
810− const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles);
811− const detail = next.detail ?? previous.detail;
812− const command = next.command ?? previous.command;
813− const rawCommand = next.rawCommand ?? previous.rawCommand;
814− const toolTitle = next.toolTitle ?? previous.toolTitle;
815− const itemType = next.itemType ?? previous.itemType;
816− const requestKind = next.requestKind ?? previous.requestKind;
817− const collapseKey = next.collapseKey ?? previous.collapseKey;
818− const toolCallId = next.toolCallId ?? previous.toolCallId;
819− const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus;
820− const toolData = next.toolData ?? previous.toolData;
821− return {
822− ...previous,
823− ...next,
824− ...(detail ? { detail } : {}),
825− ...(command ? { command } : {}),
826− ...(rawCommand ? { rawCommand } : {}),
827− ...(changedFiles.length > 0 ? { changedFiles } : {}),
828− ...(toolTitle ? { toolTitle } : {}),
829− ...(itemType ? { itemType } : {}),
830− ...(requestKind ? { requestKind } : {}),
831− ...(collapseKey ? { collapseKey } : {}),
832− ...(toolCallId ? { toolCallId } : {}),
833− ...(toolLifecycleStatus !== undefined ? { toolLifecycleStatus } : {}),
834− ...(toolData !== undefined ? { toolData } : {}),
835− };
276+ return plan !== null && plan.implementedAt === null;
836277 }
837278
838−function mergeChangedFiles(
839− previous: ReadonlyArray<string> | undefined,
840− next: ReadonlyArray<string> | undefined,
841−): string[] {
842− const merged = [...(previous ?? []), ...(next ?? [])];
843− if (merged.length === 0) {
844− return [];
845− }
846− return [...new Set(merged)];
847−}
848−
849−function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | undefined {
850− if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") {
851− return undefined;
852− }
853− if (entry.toolCallId) {
854− return `tool:${entry.toolCallId}`;
855− }
856− const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label);
857− const detail = entry.detail?.trim() ?? "";
858− const itemType = entry.itemType ?? "";
859− if (normalizedLabel.length === 0 && detail.length === 0 && itemType.length === 0) {
860− return undefined;
861− }
862− return [itemType, normalizedLabel, detail].join("\u001f");
279+export function deriveWorkLogEntries(entries: ReadonlyArray<ThreadWorkEntry>): WorkLogEntry[] {
280+ return entries.map((entry) => ({ ...entry, sourceItemType: entry.itemType }));
863281 }
864282
865−function normalizeCompactToolLabel(value: string): string {
866− return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
867−}
868−
869−function toLatestProposedPlanState(proposedPlan: ProposedPlan): LatestProposedPlanState {
870− return {
871− id: proposedPlan.id,
872− createdAt: proposedPlan.createdAt,
873− updatedAt: proposedPlan.updatedAt,
874− turnId: proposedPlan.turnId,
875− planMarkdown: proposedPlan.planMarkdown,
876− implementedAt: proposedPlan.implementedAt,
877− implementationThreadId: proposedPlan.implementationThreadId,
878− };
879−}
880−
881−function asRecord(value: unknown): Record<string, unknown> | null {
882− return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
883−}
884−
885−function asTrimmedString(value: unknown): string | null {
886− if (typeof value !== "string") {
887− return null;
888− }
889− const trimmed = value.trim();
890− return trimmed.length > 0 ? trimmed : null;
891−}
892−
893−function asNumber(value: unknown): number | null {
894− return typeof value === "number" && Number.isFinite(value) ? value : null;
895−}
896−
897−function trimMatchingOuterQuotes(value: string): string {
898− const trimmed = value.trim();
899− if (
900− (trimmed.startsWith("'") && trimmed.endsWith("'")) ||
901− (trimmed.startsWith('"') && trimmed.endsWith('"'))
902− ) {
903− const unquoted = trimmed.slice(1, -1).trim();
904− return unquoted.length > 0 ? unquoted : trimmed;
905− }
906− return trimmed;
907−}
908−
909−function executableBasename(value: string): string | null {
910− const trimmed = trimMatchingOuterQuotes(value);
911− if (trimmed.length === 0) {
912− return null;
913− }
914− const normalized = trimmed.replace(/\\/g, "/");
915− const segments = normalized.split("/");
916− const last = segments.at(-1)?.trim() ?? "";
917− return last.length > 0 ? last.toLowerCase() : null;
918−}
919−
920−function splitExecutableAndRest(value: string): { executable: string; rest: string } | null {
921− const trimmed = value.trim();
922− if (trimmed.length === 0) {
923− return null;
924− }
925−
926− if (trimmed.startsWith('"') || trimmed.startsWith("'")) {
927− const quote = trimmed.charAt(0);
928− const closeIndex = trimmed.indexOf(quote, 1);
929− if (closeIndex <= 0) {
930− return null;
931− }
932− return {
933− executable: trimmed.slice(0, closeIndex + 1),
934− rest: trimmed.slice(closeIndex + 1).trim(),
935− };
936− }
937−
938− const firstWhitespace = trimmed.search(/\s/);
939− if (firstWhitespace < 0) {
940− return {
941− executable: trimmed,
942− rest: "",
943− };
944− }
945−
946− return {
947− executable: trimmed.slice(0, firstWhitespace),
948− rest: trimmed.slice(firstWhitespace).trim(),
949− };
950−}
951−
952−const SHELL_WRAPPER_SPECS = [
953− {
954− executables: ["pwsh", "pwsh.exe", "powershell", "powershell.exe"],
955− wrapperFlagPattern: /(?:^|\s)-command\s+/i,
956− },
957− {
958− executables: ["cmd", "cmd.exe"],
959− wrapperFlagPattern: /(?:^|\s)\/c\s+/i,
960− },
961− {
962− executables: ["bash", "sh", "zsh"],
963− wrapperFlagPattern: /(?:^|\s)-(?:l)?c\s+/i,
964− },
965−] as const;
966−
967−function findShellWrapperSpec(shell: string) {
968− return SHELL_WRAPPER_SPECS.find((spec) =>
969− (spec.executables as ReadonlyArray<string>).includes(shell),
283+export function deriveTimelineEntries(
284+ messages: ReadonlyArray<ChatMessage>,
285+ proposedPlans: ReadonlyArray<ThreadProposedPlan>,
286+ workEntries: ReadonlyArray<WorkLogEntry>,
287+): TimelineEntry[] {
288+ return [
289+ ...messages.map(
290+ (message): TimelineEntry => ({
291+ id: message.id,
292+ kind: "message",
293+ createdAt: message.createdAt,
294+ message,
295+ }),
296+ ),
297+ ...proposedPlans.map(
298+ (proposedPlan): TimelineEntry => ({
299+ id: proposedPlan.id,
300+ kind: "proposed-plan",
301+ createdAt: proposedPlan.createdAt,
302+ proposedPlan,
303+ }),
304+ ),
305+ ...workEntries.map(
306+ (entry): TimelineEntry => ({
307+ id: entry.id,
308+ kind: "work",
309+ createdAt: entry.createdAt,
310+ entry,
311+ }),
312+ ),
313+ ].toSorted(
314+ (left, right) =>
315+ left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id),
970316 );
971317 }
972318
973−function unwrapCommandRemainder(value: string, wrapperFlagPattern: RegExp): string | null {
974− const match = wrapperFlagPattern.exec(value);
975− if (!match) {
976− return null;
977− }
978−
979− const command = value.slice(match.index + match[0].length).trim();
980− if (command.length === 0) {
981− return null;
982− }
983−
984− const unwrapped = trimMatchingOuterQuotes(command);
985− return unwrapped.length > 0 ? unwrapped : null;
986−}
987−
988−function unwrapKnownShellCommandWrapper(value: string): string {
989− const split = splitExecutableAndRest(value);
990− if (!split || split.rest.length === 0) {
991− return value;
992− }
993−
994− const shell = executableBasename(split.executable);
995− if (!shell) {
996− return value;
997− }
998−
999− const spec = findShellWrapperSpec(shell);
1000− if (!spec) {
1001− return value;
1002− }
1003−
1004− return unwrapCommandRemainder(split.rest, spec.wrapperFlagPattern) ?? value;
1005−}
1006−
1007−function formatCommandArrayPart(value: string): string {
1008− return /[\s"'`]/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value;
1009−}
1010−
1011−function formatCommandValue(value: unknown): string | null {
1012− const direct = asTrimmedString(value);
1013− if (direct) {
1014− return direct;
1015− }
1016− if (!Array.isArray(value)) {
1017− return null;
1018− }
1019− const parts: Array<string> = [];
1020− for (const entry of value) {
1021− const part = asTrimmedString(entry);
1022− if (part !== null) {
1023− parts.push(part);
1024− }
1025− }
1026− if (parts.length === 0) {
1027− return null;
1028− }
1029− return parts.map((part) => formatCommandArrayPart(part)).join(" ");
1030−}
1031−
1032−function normalizeCommandValue(value: unknown): string | null {
1033− const formatted = formatCommandValue(value);
1034− return formatted ? unwrapKnownShellCommandWrapper(formatted) : null;
1035−}
1036−
1037−function toRawToolCommand(value: unknown, normalizedCommand: string | null): string | null {
1038− const formatted = formatCommandValue(value);
1039− if (!formatted || normalizedCommand === null) {
1040− return null;
319+const STANDALONE_V2_ITEM_TYPES = new Set<OrchestrationV2ProjectedTurnItem["item"]["type"]>([
320+ "approval_request",
321+ "compaction",
322+ "fork",
323+ "handoff",
324+ "run_interrupt_request",
325+ "run_interrupt_result",
326+ "subagent",
327+ "todo_list",
328+ "user_input_request",
329+]);
330+
331+function projectedItemCreatedAt(row: OrchestrationV2ProjectedTurnItem): string {
332+ return DateTime.formatIso(row.item.startedAt ?? row.item.updatedAt);
333+}
334+
335+function projectedWorkEntryStatus(
336+ item: OrchestrationV2TurnItem,
337+): NonNullable<WorkLogEntry["toolLifecycleStatus"]> {
338+ switch (item.status) {
339+ case "pending":
340+ case "running":
341+ case "waiting":
342+ return "inProgress";
343+ case "completed":
344+ return "completed";
345+ case "failed":
346+ return "failed";
347+ case "cancelled":
348+ case "interrupted":
349+ return "stopped";
350+ }
351+}
352+
353+function projectedWorkEntryTone(item: OrchestrationV2TurnItem): WorkLogEntry["tone"] {
354+ if (item.status === "failed") return "error";
355+ if (item.type === "reasoning") return "thinking";
356+ switch (item.type) {
357+ case "command_execution":
358+ case "file_change":
359+ case "file_search":
360+ case "web_search":
361+ case "dynamic_tool":
362+ case "subagent":
363+ return "tool";
364+ default:
365+ return "info";
366+ }
367+}
368+
369+function projectedWorkEntry(row: OrchestrationV2ProjectedTurnItem): WorkLogEntry {
370+ const { item } = row;
371+ const title = item.title?.trim() || null;
372+ const common = {
373+ id: item.id,
374+ createdAt: projectedItemCreatedAt(row),
375+ runId: item.runId,
376+ tone: projectedWorkEntryTone(item),
377+ itemType: item.type,
378+ toolLifecycleStatus: projectedWorkEntryStatus(item),
379+ structuredPayload: item,
380+ projectedItem: row,
381+ } as const;
382+
383+ switch (item.type) {
384+ case "reasoning":
385+ return {
386+ ...common,
387+ label: title ?? "Thinking",
388+ ...(item.text ? { detail: item.text } : {}),
389+ };
390+ case "command_execution":
391+ return {
392+ ...common,
393+ label: title ?? "Ran command",
394+ command: item.input,
395+ rawCommand: item.input,
396+ ...(item.output ? { detail: item.output } : {}),
397+ toolTitle: title ?? "Command",
398+ toolData: item,
399+ };
400+ case "file_change":
401+ return {
402+ ...common,
403+ label: title ?? `Changed ${item.fileName}`,
404+ changedFiles: [item.fileName],
405+ ...(item.diffStr ? { detail: item.diffStr } : {}),
406+ toolTitle: title ?? "File change",
407+ toolData: item,
408+ };
409+ case "file_search":
410+ return {
411+ ...common,
412+ label: title ?? "Searched files",
413+ ...(item.pattern ? { detail: item.pattern } : {}),
414+ toolTitle: title ?? "File search",
415+ toolData: item,
416+ };
417+ case "web_search":
418+ return {
419+ ...common,
420+ label: title ?? "Searched the web",
421+ ...(item.patterns?.length ? { detail: item.patterns.join(", ") } : {}),
422+ toolTitle: title ?? "Web search",
423+ toolData: item,
424+ };
425+ case "checkpoint":
426+ return {
427+ ...common,
428+ label: title ?? "Checkpoint captured",
429+ changedFiles: item.files.map((file) => file.path),
430+ toolData: item,
431+ };
432+ case "dynamic_tool":
433+ return {
434+ ...common,
435+ label: title ?? item.toolName ?? "Tool call",
436+ toolTitle: title ?? item.toolName ?? "Tool",
437+ toolData: { input: item.input, output: item.output },
438+ };
439+ default:
440+ return {
441+ ...common,
442+ label: title ?? item.type.replaceAll("_", " "),
443+ toolData: item,
444+ };
1041445 }
1042− return formatted === normalizedCommand ? null : formatted;
1043446 }
1044447
1045−function extractToolCommand(payload: Record<string, unknown> | null): {
1046− command: string | null;
1047− rawCommand: string | null;
1048−} {
1049− const data = asRecord(payload?.data);
1050− const item = asRecord(data?.item);
1051− const itemResult = asRecord(item?.result);
1052− const itemInput = asRecord(item?.input);
1053− const itemType = asTrimmedString(payload?.itemType);
1054− const detail = asTrimmedString(payload?.detail);
1055− const candidates: unknown[] = [
1056− item?.command,
1057− itemInput?.command,
1058− itemResult?.command,
1059− data?.command,
1060− itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null,
1061− ];
1062−
1063− for (const candidate of candidates) {
1064− const command = normalizeCommandValue(candidate);
1065− if (!command) {
448+/**
449+ * Builds the web timeline in the exact order committed by `visibleTurnItems`.
450+ * Committed rows are presented directly from their projected item. Optimistic
451+ * messages are the only client-owned entries appended to that sequence.
452+ */
453+export function deriveTimelineEntriesFromVisibleTurnItems(input: {
454+ readonly visibleTurnItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem>;
455+ readonly optimisticMessages: ReadonlyArray<ChatMessage>;
456+ readonly attachmentUrlById?: ReadonlyMap<string, string>;
457+}): TimelineEntry[] {
458+ const committedMessageIds = new Set<string>();
459+ const entries: TimelineEntry[] = [];
460+
461+ for (const row of input.visibleTurnItems) {
462+ const { item } = row;
463+ const createdAt = projectedItemCreatedAt(row);
464+ if (item.type === "user_message" || item.type === "assistant_message") {
465+ const message: ChatMessage = {
466+ id: item.messageId,
467+ role: item.type === "user_message" ? "user" : "assistant",
468+ text: item.text,
469+ ...(item.type === "user_message" && item.attachments.length > 0
470+ ? {
471+ attachments: item.attachments.map((attachment) => {
472+ const previewUrl = input.attachmentUrlById?.get(attachment.id);
473+ return previewUrl ? { ...attachment, previewUrl } : attachment;
474+ }),
475+ }
476+ : {}),
477+ runId: item.runId,
478+ streaming: item.type === "assistant_message" && item.streaming,
479+ createdAt,
480+ updatedAt: DateTime.formatIso(item.updatedAt),
481+ };
482+ committedMessageIds.add(message.id);
483+ entries.push({ id: message.id, kind: "message", createdAt, message });
1066484 continue;
1067485 }
1068− return {
1069− command,
1070− rawCommand: toRawToolCommand(candidate, command),
1071− };
1072− }
1073486
1074− return {
1075− command: null,
1076− rawCommand: null,
1077− };
1078−}
1079−
1080−function extractToolTitle(payload: Record<string, unknown> | null): string | null {
1081− return asTrimmedString(payload?.title);
1082−}
1083−
1084−function extractToolCallId(payload: Record<string, unknown> | null): string | null {
1085− const data = asRecord(payload?.data);
1086− return asTrimmedString(data?.toolCallId);
1087−}
1088−
1089−function normalizeInlinePreview(value: string): string {
1090− return value.replace(/\s+/g, " ").trim();
1091−}
1092−
1093−function truncateInlinePreview(value: string, maxLength = 84): string {
1094− if (value.length <= maxLength) {
1095− return value;
1096− }
1097− return `${value.slice(0, maxLength - 1).trimEnd()}…`;
1098−}
1099−
1100−function normalizePreviewForComparison(value: string | null | undefined): string | null {
1101− const normalized = asTrimmedString(value);
1102− if (!normalized) {
1103− return null;
1104− }
1105− return normalizeCompactToolLabel(normalizeInlinePreview(normalized)).toLowerCase();
1106−}
1107−
1108−function summarizeToolTextOutput(value: string): string | null {
1109− const lines: Array<string> = [];
1110− for (const rawLine of value.split(/\r?\n/u)) {
1111− const line = normalizeInlinePreview(rawLine);
1112− if (line.length > 0) {
1113− lines.push(line);
1114− }
1115− }
1116− const firstLine = lines.find((line) => line !== "```");
1117− if (firstLine) {
1118− return truncateInlinePreview(firstLine);
1119− }
1120− if (lines.length > 1) {
1121− return `${lines.length.toLocaleString()} lines`;
1122− }
1123− return null;
1124−}
1125−
1126−function summarizeToolRawOutput(payload: Record<string, unknown> | null): string | null {
1127− const data = asRecord(payload?.data);
1128− const rawOutput = asRecord(data?.rawOutput);
1129− if (!rawOutput) {
1130− return null;
1131− }
1132−
1133− const totalFiles = asNumber(rawOutput.totalFiles);
1134− if (totalFiles !== null) {
1135− const suffix = rawOutput.truncated === true ? "+" : "";
1136− return `${totalFiles.toLocaleString()} file${totalFiles === 1 ? "" : "s"}${suffix}`;
1137− }
1138−
1139− const content = asTrimmedString(rawOutput.content);
1140− if (content) {
1141− return summarizeToolTextOutput(content);
1142− }
1143−
1144− const stdout = asTrimmedString(rawOutput.stdout);
1145− if (stdout) {
1146− return summarizeToolTextOutput(stdout);
1147− }
1148−
1149− return null;
1150−}
1151−
1152−function isCommandToolDetail(payload: Record<string, unknown> | null, heading: string): boolean {
1153− const data = asRecord(payload?.data);
1154− const kind = asTrimmedString(data?.kind)?.toLowerCase();
1155− const title = asTrimmedString(payload?.title ?? heading)?.toLowerCase();
1156− return (
1157− extractWorkLogItemType(payload) === "command_execution" ||
1158− kind === "execute" ||
1159− title === "terminal" ||
1160− title === "ran command"
1161− );
1162−}
1163−
1164−function extractToolDetail(
1165− payload: Record<string, unknown> | null,
1166− heading: string,
1167−): string | null {
1168− const rawDetail = asTrimmedString(payload?.detail);
1169− const detail = rawDetail ? stripTrailingExitCode(rawDetail).output : null;
1170− const normalizedHeading = normalizePreviewForComparison(heading);
1171− const normalizedDetail = normalizePreviewForComparison(detail);
1172−
1173− if (detail && normalizedHeading !== normalizedDetail) {
1174− return detail;
1175− }
1176−
1177− if (isCommandToolDetail(payload, heading)) {
1178− return null;
1179− }
1180−
1181− const rawOutputSummary = summarizeToolRawOutput(payload);
1182− if (rawOutputSummary) {
1183− const normalizedRawOutputSummary = normalizePreviewForComparison(rawOutputSummary);
1184− if (normalizedRawOutputSummary !== normalizedHeading) {
1185− return rawOutputSummary;
1186− }
1187− }
1188−
1189− return null;
1190−}
1191−
1192−function stripTrailingExitCode(value: string): {
1193− output: string | null;
1194− exitCode?: number | undefined;
1195−} {
1196− const trimmed = value.trim();
1197− const match = /^(?<output>[\s\S]*?)(?:\s*<exited with exit code (?<code>\d+)>)\s*$/i.exec(
1198− trimmed,
1199− );
1200− if (!match?.groups) {
1201− return {
1202− output: trimmed.length > 0 ? trimmed : null,
1203− };
1204− }
1205− const exitCode = Number.parseInt(match.groups.code ?? "", 10);
1206− const normalizedOutput = match.groups.output?.trim() ?? "";
1207− return {
1208− output: normalizedOutput.length > 0 ? normalizedOutput : null,
1209− ...(Number.isInteger(exitCode) ? { exitCode } : {}),
1210− };
1211−}
1212−
1213−function extractWorkLogItemType(
1214− payload: Record<string, unknown> | null,
1215−): WorkLogEntry["itemType"] | undefined {
1216− if (typeof payload?.itemType === "string" && isToolLifecycleItemType(payload.itemType)) {
1217− return payload.itemType;
1218− }
1219− return undefined;
1220−}
1221−
1222−function extractWorkLogRequestKind(
1223− payload: Record<string, unknown> | null,
1224−): WorkLogEntry["requestKind"] | undefined {
1225− if (
1226− payload?.requestKind === "command" ||
1227− payload?.requestKind === "file-read" ||
1228− payload?.requestKind === "file-change"
1229− ) {
1230− return payload.requestKind;
1231− }
1232− return requestKindFromRequestType(payload?.requestType) ?? undefined;
1233−}
1234−
1235−function pushChangedFile(target: string[], seen: Set<string>, value: unknown) {
1236− const normalized = asTrimmedString(value);
1237− if (!normalized || seen.has(normalized)) {
1238− return;
1239− }
1240− seen.add(normalized);
1241− target.push(normalized);
1242−}
1243−
1244−function collectChangedFiles(value: unknown, target: string[], seen: Set<string>, depth: number) {
1245− if (depth > 4 || target.length >= 12) {
1246− return;
1247− }
1248− if (Array.isArray(value)) {
1249− for (const entry of value) {
1250− collectChangedFiles(entry, target, seen, depth + 1);
1251− if (target.length >= 12) {
1252− return;
1253− }
1254− }
1255− return;
1256− }
1257−
1258− const record = asRecord(value);
1259− if (!record) {
1260− return;
1261− }
1262−
1263− pushChangedFile(target, seen, record.path);
1264− pushChangedFile(target, seen, record.filePath);
1265− pushChangedFile(target, seen, record.relativePath);
1266− pushChangedFile(target, seen, record.filename);
1267− pushChangedFile(target, seen, record.newPath);
1268− pushChangedFile(target, seen, record.oldPath);
1269−
1270− for (const nestedKey of [
1271− "item",
1272− "result",
1273− "input",
1274− "data",
1275− "changes",
1276− "files",
1277− "edits",
1278− "patch",
1279− "patches",
1280− "operations",
1281− ]) {
1282− if (!(nestedKey in record)) {
487+ if (item.type === "proposed_plan") {
488+ const proposedPlan = {
489+ id: item.planId,
490+ runId: item.runId,
491+ planMarkdown: item.markdown,
492+ status: "active" as const,
493+ implementedAt: null,
494+ implementationThreadId: null,
495+ createdAt,
496+ updatedAt: DateTime.formatIso(item.updatedAt),
497+ };
498+ entries.push({ id: item.id, kind: "proposed-plan", createdAt, proposedPlan });
1283499 continue;
1284500 }
1285− collectChangedFiles(record[nestedKey], target, seen, depth + 1);
1286− if (target.length >= 12) {
1287− return;
1288− }
1289− }
1290−}
1291−
1292−function extractChangedFiles(payload: Record<string, unknown> | null): string[] {
1293− const changedFiles: string[] = [];
1294− const seen = new Set<string>();
1295− collectChangedFiles(asRecord(payload?.data), changedFiles, seen, 0);
1296− return changedFiles;
1297−}
1298501
1299−function compareActivitiesByOrder(
1300− left: OrchestrationThreadActivity,
1301− right: OrchestrationThreadActivity,
1302−): number {
1303− if (left.sequence !== undefined && right.sequence !== undefined) {
1304− if (left.sequence !== right.sequence) {
1305− return left.sequence - right.sequence;
502+ if (STANDALONE_V2_ITEM_TYPES.has(item.type)) {
503+ entries.push({ id: item.id, kind: "event", createdAt, projectedItem: row });
504+ continue;
1306505 }
1307− } else if (left.sequence !== undefined) {
1308− return 1;
1309− } else if (right.sequence !== undefined) {
1310− return -1;
1311− }
1312506
1313− const createdAtComparison = left.createdAt.localeCompare(right.createdAt);
1314− if (createdAtComparison !== 0) {
1315− return createdAtComparison;
507+ entries.push({ id: item.id, kind: "work", createdAt, entry: projectedWorkEntry(row) });
1316508 }
1317509
1318− const lifecycleRankComparison =
1319− compareActivityLifecycleRank(left.kind) - compareActivityLifecycleRank(right.kind);
1320− if (lifecycleRankComparison !== 0) {
1321− return lifecycleRankComparison;
510+ for (const message of input.optimisticMessages) {
511+ if (!committedMessageIds.has(message.id)) {
512+ entries.push({
513+ id: message.id,
514+ kind: "message",
515+ createdAt: message.createdAt,
516+ message,
517+ });
518+ }
1322519 }
1323520
1324− return left.id.localeCompare(right.id);
521+ return entries;
1325522 }
1326523
1327−function compareActivityLifecycleRank(kind: string): number {
1328− if (kind.endsWith(".started") || kind === "tool.started") {
1329− return 0;
1330− }
1331− if (kind.endsWith(".progress") || kind.endsWith(".updated")) {
1332− return 1;
1333− }
1334− if (kind.endsWith(".completed") || kind.endsWith(".resolved")) {
1335− return 2;
1336− }
1337− return 1;
1338−}
1339−
1340−export function deriveTimelineEntries(
1341− messages: ReadonlyArray<ChatMessage>,
1342− proposedPlans: ReadonlyArray<ProposedPlan>,
1343− workEntries: ReadonlyArray<WorkLogEntry>,
1344−): TimelineEntry[] {
1345− const messageRows: TimelineEntry[] = messages.map((message) => ({
1346− id: message.id,
1347− kind: "message",
1348− createdAt: message.createdAt,
1349− message,
1350− }));
1351− const proposedPlanRows: TimelineEntry[] = proposedPlans.map((proposedPlan) => ({
1352− id: proposedPlan.id,
1353− kind: "proposed-plan",
1354− createdAt: proposedPlan.createdAt,
1355− proposedPlan,
1356− }));
1357− const workRows: TimelineEntry[] = workEntries.map((entry) => ({
1358− id: entry.id,
1359− kind: "work",
1360− createdAt: entry.createdAt,
1361− entry,
1362− }));
1363− return [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) =>
1364− a.createdAt.localeCompare(b.createdAt),
524+export function inferCheckpointTurnCountByRunId(
525+ summaries: ReadonlyArray<ThreadCheckpointSummary>,
526+): Record<string, number> {
527+ return Object.fromEntries(
528+ summaries.flatMap((summary) =>
529+ summary.runId === null ? [] : [[summary.runId, summary.checkpointTurnCount] as const],
530+ ),
1365531 );
1366532 }
1367533
1368−export function inferCheckpointTurnCountByTurnId(
1369− summaries: ReadonlyArray<TurnDiffSummary>,
1370−): Record<TurnId, number> {
1371− const sorted = [...summaries].toSorted((a, b) => a.completedAt.localeCompare(b.completedAt));
1372− const result: Record<TurnId, number> = {};
1373− for (let index = 0; index < sorted.length; index += 1) {
1374− const summary = sorted[index];
1375− if (!summary) continue;
1376− result[summary.turnId] = index + 1;
1377− }
1378− return result;
1379−}
1380−
1381−export function derivePhase(session: ThreadSession | null): SessionPhase {
1382− if (
1383− !session ||
1384− session.status === "stopped" ||
1385− session.status === "interrupted" ||
1386− session.status === "error"
1387− ) {
1388− return "disconnected";
1389− }
1390− if (session.status === "starting") return "connecting";
1391− if (session.status === "running") return "running";
534+export function derivePhase(runtime: ThreadRuntimeSummary | null): SessionPhase {
535+ if (runtime === null) return "disconnected";
536+ if (runtime.status === "starting" || runtime.status === "queued") return "connecting";
537+ if (runtime.status === "running" || runtime.status === "waiting") return "running";
1392538 return "ready";
1393539 }
540+
541+export type { TurnDiffSummary };
apps/web/src/state/entities.ts+34−29
@@ -3,21 +3,14 @@ import type { · 2 unmodified lines
33 EnvironmentProject,
44 EnvironmentThread,
55 EnvironmentThreadShell,
6+ ThreadConversationMessage,
7+ ThreadProposedPlan,
8+ ThreadRuntimeSummary,
9+ ThreadWorkEntry,
610 } from "@t3tools/client-runtime/state/shell";
7−import {
8− type EnvironmentThreadStatus,
9− mergeEnvironmentThread,
10−} from "@t3tools/client-runtime/state/threads";
11−import type {
12− OrchestrationMessage,
13− OrchestrationProposedPlan,
14− OrchestrationSession,
15− OrchestrationThreadActivity,
16− ScopedProjectRef,
17− ScopedThreadRef,
18− ServerConfig,
19−} from "@t3tools/contracts";
20−import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
11+import { mergeEnvironmentThread } from "@t3tools/client-runtime/state/threads";
12+import type { ScopedProjectRef, ScopedThreadRef, ServerConfig } from "@t3tools/contracts";
13+import type { EnvironmentId, OrchestrationV2ProjectedTurnItem, ThreadId } from "@t3tools/contracts";
2114 import { Atom } from "effect/unstable/reactivity";
2215 import { useMemo } from "react";
2316 import { appAtomRegistry } from "../rpc/atomRegistry";
@@ -28,9 +21,10 @@ import { environmentThreadDetails, environmentThreadShells } from "./threads"; · 4 unmodified lines
2821
2922 const EMPTY_PROJECT_REFS: ReadonlyArray<ScopedProjectRef> = Object.freeze([]);
3023 const EMPTY_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]);
31−const EMPTY_MESSAGES: ReadonlyArray<OrchestrationMessage> = Object.freeze([]);
32−const EMPTY_ACTIVITIES: ReadonlyArray<OrchestrationThreadActivity> = Object.freeze([]);
33−const EMPTY_PROPOSED_PLANS: ReadonlyArray<OrchestrationProposedPlan> = Object.freeze([]);
24+const EMPTY_MESSAGES: ReadonlyArray<ThreadConversationMessage> = Object.freeze([]);
25+const EMPTY_WORK_ENTRIES: ReadonlyArray<ThreadWorkEntry> = Object.freeze([]);
26+const EMPTY_PROPOSED_PLANS: ReadonlyArray<ThreadProposedPlan> = Object.freeze([]);
27+const EMPTY_VISIBLE_TURN_ITEMS: ReadonlyArray<OrchestrationV2ProjectedTurnItem> = Object.freeze([]);
3428
3529 const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe(
3630 Atom.withLabel("web-project:empty"),
@@ -53,14 +47,17 @@ const EMPTY_THREAD_STATUS_ATOM = Atom.make<EnvironmentThreadStatus>("empty").pip · 16 unmodified lines
5347 const EMPTY_MESSAGES_ATOM = Atom.make(EMPTY_MESSAGES).pipe(
5448 Atom.withLabel("web-thread-messages:empty"),
5549 );
56−const EMPTY_ACTIVITIES_ATOM = Atom.make(EMPTY_ACTIVITIES).pipe(
57− Atom.withLabel("web-thread-activities:empty"),
50+const EMPTY_WORK_ENTRIES_ATOM = Atom.make(EMPTY_WORK_ENTRIES).pipe(
51+ Atom.withLabel("web-thread-work-entries:empty"),
5852 );
5953 const EMPTY_PROPOSED_PLANS_ATOM = Atom.make(EMPTY_PROPOSED_PLANS).pipe(
6054 Atom.withLabel("web-thread-proposed-plans:empty"),
6155 );
62−const EMPTY_SESSION_ATOM = Atom.make<OrchestrationSession | null>(null).pipe(
63− Atom.withLabel("web-thread-session:empty"),
56+const EMPTY_VISIBLE_TURN_ITEMS_ATOM = Atom.make(EMPTY_VISIBLE_TURN_ITEMS).pipe(
57+ Atom.withLabel("web-thread-visible-turn-items:empty"),
58+);
59+const EMPTY_RUNTIME_ATOM = Atom.make<ThreadRuntimeSummary | null>(null).pipe(
60+ Atom.withLabel("web-thread-runtime:empty"),
6461 );
6562
6663 export const activeEnvironmentIdAtom = Atom.make<EnvironmentId | null>(null).pipe(
@@ -186,31 +183,39 @@ export function useThread( · 119 unmodified lines
186183
187184 export function useThreadMessages(
188185 ref: ScopedThreadRef | null,
189−): ReadonlyArray<OrchestrationMessage> {
186+): ReadonlyArray<ThreadConversationMessage> {
190187 return useAtomValue(
191188 ref === null ? EMPTY_MESSAGES_ATOM : environmentThreadDetails.messagesAtom(ref),
192189 );
193190 }
194191
195−export function useThreadActivities(
196− ref: ScopedThreadRef | null,
197−): ReadonlyArray<OrchestrationThreadActivity> {
192+export function useThreadWorkEntries(ref: ScopedThreadRef | null): ReadonlyArray<ThreadWorkEntry> {
198193 return useAtomValue(
199− ref === null ? EMPTY_ACTIVITIES_ATOM : environmentThreadDetails.activitiesAtom(ref),
194+ ref === null ? EMPTY_WORK_ENTRIES_ATOM : environmentThreadDetails.workEntriesAtom(ref),
200195 );
201196 }
202197
203198 export function useThreadProposedPlans(
204199 ref: ScopedThreadRef | null,
205−): ReadonlyArray<OrchestrationProposedPlan> {
200+): ReadonlyArray<ThreadProposedPlan> {
206201 return useAtomValue(
207202 ref === null ? EMPTY_PROPOSED_PLANS_ATOM : environmentThreadDetails.proposedPlansAtom(ref),
208203 );
209204 }
210205
211−export function useThreadSession(ref: ScopedThreadRef | null): OrchestrationSession | null {
206+export function useThreadVisibleTurnItems(
207+ ref: ScopedThreadRef | null,
208+): ReadonlyArray<OrchestrationV2ProjectedTurnItem> {
209+ return useAtomValue(
210+ ref === null
211+ ? EMPTY_VISIBLE_TURN_ITEMS_ATOM
212+ : environmentThreadDetails.visibleTurnItemsAtom(ref),
213+ );
214+}
215+
216+export function useThreadRuntime(ref: ScopedThreadRef | null): ThreadRuntimeSummary | null {
212217 return useAtomValue(
213− ref === null ? EMPTY_SESSION_ATOM : environmentThreadDetails.sessionAtom(ref),
218+ ref === null ? EMPTY_RUNTIME_ATOM : environmentThreadDetails.runtimeAtom(ref),
214219 );
215220 }
216221
apps/web/src/state/queries.ts+3−10
@@ -9,15 +9,8 @@ import { · 8 unmodified lines
99 type EnvironmentThreadSearchMatch,
1010 } from "@t3tools/client-runtime/state/thread-search";
1111 import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs";
12−import type {
13− EnvironmentId,
14− OrchestrationThread,
15− ProjectContentMatch,
16− ProjectEntryKind,
17− ThreadId,
18− VcsListRefsResult,
19− VcsRef,
20−} from "@t3tools/contracts";
12+import type { EnvironmentId, ThreadId, VcsListRefsResult, VcsRef } from "@t3tools/contracts";
13+import type { EnvironmentThread } from "@t3tools/client-runtime/state/shell";
2114 import * as Cause from "effect/Cause";
2215 import * as Option from "effect/Option";
2316 import { AsyncResult, Atom } from "effect/unstable/reactivity";
@@ -56,7 +49,7 @@ const threadSearchResultsAtom = createThreadSearchResultsAtomFamily({ · 32 unmodified lines
5649 });
5750
5851 export interface ThreadDetailView {
59− readonly data: OrchestrationThread | null;
52+ readonly data: EnvironmentThread | null;
6053 readonly error: string | null;
6154 readonly isPending: boolean;
6255 readonly isDeleted: boolean;
apps/web/src/state/threads.ts+10−2
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
77 type EnvironmentThreadState,
88 createThreadEnvironmentAtoms,
99 } from "@t3tools/client-runtime/state/threads";
10+import { presentThread, type EnvironmentThread } from "@t3tools/client-runtime/state/shell";
1011 import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
1112 import * as Option from "effect/Option";
1213 import { AsyncResult, Atom } from "effect/unstable/reactivity";
@@ -32,14 +33,21 @@ const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_ · 19 unmodified lines
3233 export function useEnvironmentThread(
3334 environmentId: EnvironmentId | null,
3435 threadId: ThreadId | null,
35−): EnvironmentThreadState {
36+): Omit<EnvironmentThreadState, "data"> & { readonly data: Option.Option<EnvironmentThread> } {
3637 const result = useAtomValue(
3738 environmentId !== null && threadId !== null
3839 ? environmentThreads.stateAtom(environmentId, threadId)
3940 : EMPTY_THREAD_STATE_ATOM,
4041 );
41− return Option.getOrElse(
42+ const state = Option.getOrElse(
4243 AsyncResult.value(result),
4344 () => EMPTY_ENVIRONMENT_THREAD_STATE,
4445 ) as EnvironmentThreadState;
46+ return {
47+ ...state,
48+ data:
49+ environmentId === null
50+ ? Option.none()
51+ : Option.map(state.data, (projection) => presentThread(environmentId, projection)),
52+ };
4553 }
apps/web/src/test-fixtures.ts+89−0
@@ -0,0 +1,89 @@
1+import { presentThread } from "@t3tools/client-runtime/state/shell";
2+import {
3+ EnvironmentId,
4+ ProjectId,
5+ ProviderInstanceId,
6+ ThreadId,
7+ type OrchestrationV2ThreadProjection,
8+} from "@t3tools/contracts";
9+import * as DateTime from "effect/DateTime";
10+
11+import type { Thread } from "./types";
12+
13+const DEFAULT_TIMESTAMP = "2026-01-01T00:00:00.000Z";
14+
15+/** Creates a structurally complete V2 thread for UI unit tests. */
16+export function makeThreadFixture(overrides: Partial<Thread> = {}): Thread {
17+ const environmentId = overrides.environmentId ?? EnvironmentId.make("environment-test");
18+ const id = overrides.id ?? ThreadId.make("thread-test");
19+ const projectId = overrides.projectId ?? ProjectId.make("project-test");
20+ const providerInstanceId =
21+ overrides.providerInstanceId ??
22+ overrides.modelSelection?.instanceId ??
23+ ProviderInstanceId.make("codex");
24+ const modelSelection = overrides.modelSelection ?? {
25+ instanceId: providerInstanceId,
26+ model: "gpt-5.4",
27+ };
28+ // Presentation-level overrides are applied after projection. Keep the source
29+ // projection valid so tests can deliberately exercise malformed persisted
30+ // timestamps without failing inside Effect's DateTime constructor first.
31+ const createdAt = DateTime.makeUnsafe(DEFAULT_TIMESTAMP);
32+ const updatedAt = DateTime.makeUnsafe(DEFAULT_TIMESTAMP);
33+ const archivedAt =
34+ overrides.archivedAt === null || overrides.archivedAt === undefined
35+ ? null
36+ : DateTime.makeUnsafe(overrides.archivedAt);
37+ const deletedAt =
38+ overrides.deletedAt === null || overrides.deletedAt === undefined
39+ ? null
40+ : DateTime.makeUnsafe(overrides.deletedAt);
41+ const projection: OrchestrationV2ThreadProjection = {
42+ thread: {
43+ id,
44+ projectId,
45+ title: overrides.title ?? "Thread",
46+ providerInstanceId,
47+ modelSelection,
48+ runtimeMode: overrides.runtimeMode ?? "full-access",
49+ interactionMode: overrides.interactionMode ?? "default",
50+ branch: overrides.branch ?? null,
51+ worktreePath: overrides.worktreePath ?? null,
52+ activeProviderThreadId: overrides.activeProviderThreadId ?? null,
53+ lineage: overrides.lineage ?? {
54+ rootThreadId: id,
55+ parentThreadId: null,
56+ relationshipToParent: null,
57+ },
58+ forkedFrom: overrides.forkedFrom ?? null,
59+ createdBy: "user",
60+ creationSource: "web",
61+ createdAt,
62+ updatedAt,
63+ archivedAt,
64+ deletedAt,
65+ },
66+ runs: [],
67+ attempts: [],
68+ nodes: [],
69+ subagents: [],
70+ providerSessions: [],
71+ providerThreads: [],
72+ providerTurns: [],
73+ runtimeRequests: [],
74+ messages: [],
75+ plans: [],
76+ turnItems: [],
77+ checkpointScopes: [],
78+ checkpoints: [],
79+ contextHandoffs: [],
80+ contextTransfers: [],
81+ visibleTurnItems: [],
82+ updatedAt,
83+ };
84+
85+ return {
86+ ...presentThread(environmentId, projection),
87+ ...overrides,
88+ };
89+}
apps/web/src/types.ts+11−12
@@ -1,11 +1,5 @@
11 import type {
22 ChatImageAttachment as ContractChatImageAttachment,
3− OrchestrationCheckpointFile,
4− OrchestrationCheckpointSummary,
5− OrchestrationLatestTurn,
6− OrchestrationMessage,
7− OrchestrationProposedPlan,
8− OrchestrationSession,
93 ProjectScript as ContractProjectScript,
104 ProviderInteractionMode,
115 RuntimeMode,
@@ -14,6 +8,11 @@ import type { · 2 unmodified lines
148 EnvironmentProject,
159 EnvironmentThread,
1610 EnvironmentThreadShell,
11+ ThreadCheckpointSummary,
12+ ThreadConversationMessage,
13+ ThreadProposedPlan,
14+ ThreadRunSummary,
15+ ThreadRuntimeSummary,
1716 } from "@t3tools/client-runtime/state/shell";
1817
1918 export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
@@ -37,21 +36,21 @@ export interface ChatImageAttachment extends ContractChatImageAttachment { · 17 unmodified lines
3736
3837 export type ChatAttachment = ChatImageAttachment;
3938
40−export interface ChatMessage extends Omit<OrchestrationMessage, "attachments"> {
39+export interface ChatMessage extends Omit<ThreadConversationMessage, "attachments"> {
4140 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
4241 }
4342
44−export type ProposedPlan = OrchestrationProposedPlan;
45−export type TurnDiffFileChange = OrchestrationCheckpointFile;
46−export type TurnDiffSummary = OrchestrationCheckpointSummary;
43+export type ProposedPlan = ThreadProposedPlan;
44+export type TurnDiffFileChange = ThreadCheckpointSummary["files"][number];
45+export type TurnDiffSummary = ThreadCheckpointSummary;
4746
4847 export type Project = EnvironmentProject;
4948 export type Thread = EnvironmentThread;
5049 export type ThreadShell = EnvironmentThreadShell;
5150
5251 export interface ThreadTurnState {
53− latestTurn: OrchestrationLatestTurn | null;
52+ latestRun: ThreadRunSummary | null;
5453 }
5554
5655 export type SidebarThreadSummary = EnvironmentThreadShell;
57−export type ThreadSession = OrchestrationSession;
56+export type ThreadSession = ThreadRuntimeSummary;
apps/web/src/worktreeCleanup.test.ts+5−6
@@ -2,12 +2,13 @@ import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools · 1 unmodified line
22 import { describe, expect, it } from "vite-plus/test";
33
44 import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types";
5+import { makeThreadFixture } from "./test-fixtures";
56 import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup";
67
78 const localEnvironmentId = EnvironmentId.make("environment-local");
89
910 function makeThread(overrides: Partial<Thread> = {}): Thread {
10− return {
11+ return makeThreadFixture({
1112 id: ThreadId.make("thread-1"),
1213 environmentId: localEnvironmentId,
1314 projectId: ProjectId.make("project-1"),
@@ -18,10 +19,8 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 4 unmodified lines
1819 },
1920 runtimeMode: DEFAULT_RUNTIME_MODE,
2021 interactionMode: DEFAULT_INTERACTION_MODE,
21− session: null,
22+ runtime: null,
2223 messages: [],
23− checkpoints: [],
24− activities: [],
2524 proposedPlans: [],
2625 createdAt: "2026-02-13T00:00:00.000Z",
2726 updatedAt: "2026-02-13T00:00:00.000Z",
@@ -29,11 +28,11 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 1 unmodified line
2928 settledOverride: null,
3029 settledAt: null,
3130 deletedAt: null,
32− latestTurn: null,
31+ latestRun: null,
3332 branch: null,
3433 worktreePath: null,
3534 ...overrides,
36− };
35+ });
3736 }
3837
3938 describe("getOrphanedWorktreePathForThread", () => {
packages/client-runtime/src/connection/registry.test.ts+4−5
@@ -1,7 +1,7 @@
11 import {
22 type DesktopSshEnvironmentTarget,
33 EnvironmentId,
4− type OrchestrationShellSnapshot,
4+ type OrchestrationV2ShellSnapshot,
55 } from "@t3tools/contracts";
66 import { describe, expect, it } from "@effect/vitest";
77 import * as Deferred from "effect/Deferred";
@@ -46,6 +46,7 @@ import * as EnvironmentRegistry from "./registry.ts"; · 38 unmodified lines
4646 import * as RpcSession from "../rpc/session.ts";
4747 import * as EnvironmentSupervisor from "./supervisor.ts";
4848 import * as ConnectionWakeups from "./wakeups.ts";
49+import { v2ShellSnapshot } from "../state/orchestrationV2TestFixtures.ts";
4950
5051 const TARGET = new PrimaryConnectionTarget({
5152 environmentId: EnvironmentId.make("environment-1"),
@@ -112,11 +113,9 @@ const SSH_PROFILE = new SshConnectionProfile({ · 60 unmodified lines
112113 target: SSH_TARGET,
113114 });
114115
115−const CACHED_SNAPSHOT: OrchestrationShellSnapshot = {
116+const CACHED_SNAPSHOT: OrchestrationV2ShellSnapshot = {
117+ ...v2ShellSnapshot,
116118 snapshotSequence: 1,
117− projects: [],
118− threads: [],
119− updatedAt: "2026-06-06T00:00:00.000Z",
120119 };
121120
122121 interface SessionControl {
packages/client-runtime/src/operations/commands.test.ts+225−71
@@ -1,13 +1,22 @@
11 import {
22 CommandId,
33 EnvironmentId,
4− ORCHESTRATION_WS_METHODS,
4+ MessageId,
5+ ORCHESTRATION_V2_WS_METHODS,
6+ PlanId,
57 ProjectId,
8+ ProviderInstanceId,
9+ RunId,
610 ThreadId,
7− type ClientOrchestrationCommand,
11+ WS_METHODS,
12+ type OrchestrationV2Command,
13+ type OrchestrationV2ThreadLaunchInput,
14+ type OrchestrationV2ThreadProjection,
15+ type ProjectMutation,
816 } from "@t3tools/contracts";
917 import { describe, expect, it } from "@effect/vitest";
1018 import * as Crypto from "effect/Crypto";
19+import * as DateTime from "effect/DateTime";
1120 import * as Effect from "effect/Effect";
1221 import * as Layer from "effect/Layer";
1322 import * as Option from "effect/Option";
@@ -21,13 +30,8 @@ import { · 7 unmodified lines
2130 import * as EnvironmentSupervisor from "../connection/supervisor.ts";
2231 import * as RpcSession from "../rpc/session.ts";
2332 import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
24−import {
25− archiveThread,
26− createProject,
27− settleThread,
28− stopThreadSession,
29− unsettleThread,
30−} from "./commands.ts";
33+import { v2Projection, v2ThreadId } from "../state/orchestrationV2TestFixtures.ts";
34+import { archiveThread, createProject, startThreadTurn } from "./commands.ts";
3135
3236 const TEST_CRYPTO_LAYER = Layer.succeed(
3337 Crypto.Crypto,
@@ -44,14 +48,45 @@ const TARGET = new PrimaryConnectionTarget({ · 10 unmodified lines
4448 wsBaseUrl: "wss://environment.example.test",
4549 });
4650
47−const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(function* (
48− dispatched: ClientOrchestrationCommand[],
49−) {
51+const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(function* (input: {
52+ readonly commands: OrchestrationV2Command[];
53+ readonly projects: ProjectMutation[];
54+ readonly launches?: OrchestrationV2ThreadLaunchInput[];
55+ readonly projection?: OrchestrationV2ThreadProjection;
56+}) {
5057 const client = {
51− [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command: ClientOrchestrationCommand) =>
58+ [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) =>
59+ Effect.sync(() => {
60+ input.commands.push(command);
61+ return { sequence: input.commands.length };
62+ }),
63+ [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: () =>
64+ Effect.succeed(input.projection ?? v2Projection),
65+ [ORCHESTRATION_V2_WS_METHODS.launchThread]: (launchInput: OrchestrationV2ThreadLaunchInput) =>
5266 Effect.sync(() => {
53− dispatched.push(command);
54− return { sequence: dispatched.length };
67+ input.launches?.push(launchInput);
68+ return {
69+ threadId: launchInput.threadId ?? v2ThreadId,
70+ projection: input.projection ?? v2Projection,
71+ resumed: false,
72+ };
73+ }),
74+ [WS_METHODS.projectsMutate]: (mutation: ProjectMutation) =>
75+ Effect.sync(() => {
76+ input.projects.push(mutation);
77+ return {
78+ id: mutation.projectId,
79+ title: mutation.type === "project.create" ? mutation.title : "Project",
80+ workspaceRoot:
81+ mutation.type === "project.create" ? mutation.workspaceRoot : "/workspace/project",
82+ repositoryIdentity: null,
83+ faviconPath: null,
84+ defaultModelSelection: null,
85+ scripts: [],
86+ createdAt: "2026-06-06T00:00:00.000Z",
87+ updatedAt: "2026-06-06T00:00:00.000Z",
88+ deletedAt: null,
89+ };
5590 }),
5691 } as unknown as WsRpcProtocolClient;
5792 const session: RpcSession.RpcSession = {
@@ -72,103 +107,222 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct · 14 unmodified lines
72107 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
73108 });
74109
75−describe("environment commands", () => {
76− it.effect("adds generated command metadata", () =>
110+describe("V2 environment commands", () => {
111+ it.effect("routes projects through the event-sourced project transport", () =>
77112 Effect.gen(function* () {
78− const dispatched: ClientOrchestrationCommand[] = [];
79− const supervisor = yield* makeSupervisor(dispatched);
113+ const projects: ProjectMutation[] = [];
114+ const supervisor = yield* makeSupervisor({ commands: [], projects });
80115
81− const result = yield* createProject({
116+ yield* createProject({
82117 projectId: ProjectId.make("project-1"),
83118 title: "Project",
84119 workspaceRoot: "/workspace/project",
85− createdAt: "2026-06-06T00:00:00.000Z",
120+ createWorkspaceRootIfMissing: true,
86121 }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
87122
88− expect(result).toEqual({ sequence: 1 });
89− expect(dispatched).toEqual([
123+ expect(projects).toEqual([
90124 {
91125 type: "project.create",
92126 commandId: "00000000-0000-4000-8000-000000000000",
93127 projectId: "project-1",
94128 title: "Project",
95129 workspaceRoot: "/workspace/project",
96− createdAt: "2026-06-06T00:00:00.000Z",
130+ createWorkspaceRootIfMissing: true,
97131 },
98132 ]);
99133 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
100134 );
101135
102− it.effect("preserves caller metadata for idempotent queued commands", () =>
136+ it.effect("preserves caller command ids for idempotent V2 commands", () =>
103137 Effect.gen(function* () {
104− const dispatched: ClientOrchestrationCommand[] = [];
105− const supervisor = yield* makeSupervisor(dispatched);
138+ const commands: OrchestrationV2Command[] = [];
139+ const supervisor = yield* makeSupervisor({ commands, projects: [] });
106140
107− yield* stopThreadSession({
141+ yield* archiveThread({
108142 commandId: CommandId.make("queued-command"),
109143 threadId: ThreadId.make("thread-1"),
110− createdAt: "2026-06-06T00:01:00.000Z",
111144 }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
112145
113− expect(dispatched).toEqual([
114− {
115− type: "thread.session.stop",
116− commandId: "queued-command",
117− threadId: "thread-1",
118− createdAt: "2026-06-06T00:01:00.000Z",
119− },
146+ expect(commands).toEqual([
147+ { type: "thread.archive", commandId: "queued-command", threadId: "thread-1" },
120148 ]);
121149 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
122150 );
123151
124− it.effect("does not add timestamps to commands without createdAt", () =>
152+ it.effect("preserves plan implementation provenance on V2 runs", () =>
125153 Effect.gen(function* () {
126− const dispatched: ClientOrchestrationCommand[] = [];
127− const supervisor = yield* makeSupervisor(dispatched);
154+ const commands: OrchestrationV2Command[] = [];
155+ const supervisor = yield* makeSupervisor({ commands, projects: [] });
128156
129− yield* archiveThread({
130− commandId: CommandId.make("archive-command"),
131− threadId: ThreadId.make("thread-1"),
157+ yield* startThreadTurn({
158+ commandId: CommandId.make("implement-plan"),
159+ threadId: v2ThreadId,
160+ message: {
161+ messageId: MessageId.make("message-implementation"),
162+ role: "user",
163+ text: "Implement the plan",
164+ attachments: [],
165+ },
166+ modelSelection: {
167+ instanceId: ProviderInstanceId.make("codex"),
168+ model: "gpt-5.4",
169+ },
170+ runtimeMode: "full-access",
171+ interactionMode: "default",
172+ sourceProposedPlan: {
173+ threadId: ThreadId.make("thread-plan"),
174+ planId: PlanId.make("plan-1"),
175+ },
132176 }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
133177
134− expect(dispatched).toEqual([
135− {
136− type: "thread.archive",
137− commandId: "archive-command",
138− threadId: "thread-1",
139− },
140− ]);
178+ expect(commands).toHaveLength(1);
179+ expect(commands[0]).toMatchObject({
180+ type: "message.dispatch",
181+ commandId: "implement-plan",
182+ threadId: v2ThreadId,
183+ sourcePlanRef: { threadId: "thread-plan", planId: "plan-1" },
184+ dispatchMode: { type: "start_immediately" },
185+ });
141186 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
142187 );
143188
144− it.effect("dispatches settle and unsettle commands without timestamps", () =>
189+ it.effect("preserves an existing worktree and branch during first-message launch", () =>
145190 Effect.gen(function* () {
146− const dispatched: ClientOrchestrationCommand[] = [];
147− const supervisor = yield* makeSupervisor(dispatched);
191+ const launches: OrchestrationV2ThreadLaunchInput[] = [];
192+ const supervisor = yield* makeSupervisor({ commands: [], projects: [], launches });
148193
149− yield* settleThread({
150− commandId: CommandId.make("settle-command"),
151− threadId: ThreadId.make("thread-1"),
152− }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
153− yield* unsettleThread({
154− commandId: CommandId.make("unsettle-command"),
155− threadId: ThreadId.make("thread-1"),
156− reason: "user",
194+ yield* startThreadTurn({
195+ commandId: CommandId.make("launch-existing-worktree"),
196+ threadId: v2ThreadId,
197+ message: {
198+ messageId: MessageId.make("message-existing-worktree"),
199+ role: "user",
200+ text: "Continue here",
201+ attachments: [],
202+ },
203+ runtimeMode: "full-access",
204+ interactionMode: "default",
205+ bootstrap: {
206+ createThread: {
207+ projectId: ProjectId.make("project-1"),
208+ title: "Thread",
209+ modelSelection: v2Projection.thread.modelSelection,
210+ runtimeMode: "full-access",
211+ interactionMode: "default",
212+ branch: "feature",
213+ worktreePath: "/workspace/project-worktrees/feature",
214+ createdAt: "2026-06-20T00:00:00.000Z",
215+ },
216+ },
157217 }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
158218
159− expect(dispatched).toEqual([
160− {
161− type: "thread.settle",
162− commandId: "settle-command",
163− threadId: "thread-1",
219+ expect(launches[0]).toMatchObject({
220+ threadId: v2ThreadId,
221+ workspaceStrategy: {
222+ type: "existing_worktree",
223+ worktreePath: "/workspace/project-worktrees/feature",
224+ branch: "feature",
164225 },
165− {
166− type: "thread.unsettle",
167− commandId: "unsettle-command",
168− threadId: "thread-1",
169− reason: "user",
226+ });
227+ }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
228+ );
229+
230+ it.effect("provisions an origin-based worktree for an existing empty thread", () =>
231+ Effect.gen(function* () {
232+ const launches: OrchestrationV2ThreadLaunchInput[] = [];
233+ const supervisor = yield* makeSupervisor({ commands: [], projects: [], launches });
234+
235+ yield* startThreadTurn({
236+ commandId: CommandId.make("launch-existing-thread-worktree"),
237+ threadId: v2ThreadId,
238+ message: {
239+ messageId: MessageId.make("message-existing-thread-worktree"),
240+ role: "user",
241+ text: "Move to a worktree",
242+ attachments: [],
170243 },
171− ]);
244+ runtimeMode: "full-access",
245+ interactionMode: "default",
246+ bootstrap: {
247+ prepareWorktree: {
248+ projectCwd: "/workspace/project",
249+ baseBranch: "main",
250+ branch: "feature",
251+ startFromOrigin: true,
252+ },
253+ },
254+ }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
255+
256+ expect(launches[0]).toMatchObject({
257+ threadId: v2ThreadId,
258+ reuseExistingThread: true,
259+ projectId: v2Projection.thread.projectId,
260+ workspaceStrategy: {
261+ type: "worktree",
262+ baseRef: "main",
263+ branch: "feature",
264+ startFromOrigin: true,
265+ },
266+ });
267+ }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
268+ );
269+
270+ it.effect("maps explicit active-run delivery modes to V2 dispatch semantics", () =>
271+ Effect.gen(function* () {
272+ const activeRunId = RunId.make("run-active");
273+ const now = DateTime.makeUnsafe("2026-06-20T01:00:00.000Z");
274+ const projection: OrchestrationV2ThreadProjection = {
275+ ...v2Projection,
276+ runs: [
277+ {
278+ id: activeRunId,
279+ threadId: v2ThreadId,
280+ ordinal: 1,
281+ providerInstanceId: v2Projection.thread.providerInstanceId,
282+ modelSelection: v2Projection.thread.modelSelection,
283+ providerThreadId: null,
284+ userMessageId: MessageId.make("message-active"),
285+ rootNodeId: null,
286+ activeAttemptId: null,
287+ status: "running",
288+ requestedAt: now,
289+ startedAt: now,
290+ completedAt: null,
291+ checkpointId: null,
292+ contextHandoffId: null,
293+ },
294+ ],
295+ };
296+ const commands: OrchestrationV2Command[] = [];
297+ const supervisor = yield* makeSupervisor({ commands, projects: [], projection });
298+
299+ for (const [mode, expectedType] of [
300+ ["queue", "queue_after_active"],
301+ ["steer", "steer_active"],
302+ ["restart", "restart_active"],
303+ ] as const) {
304+ yield* startThreadTurn({
305+ commandId: CommandId.make(`command-${mode}`),
306+ threadId: v2ThreadId,
307+ message: {
308+ messageId: MessageId.make(`message-${mode}`),
309+ role: "user",
310+ text: mode,
311+ attachments: [],
312+ },
313+ runtimeMode: "full-access",
314+ interactionMode: "default",
315+ dispatchMode: mode,
316+ }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
317+
318+ expect(commands.at(-1)).toMatchObject({
319+ type: "message.dispatch",
320+ dispatchMode: {
321+ type: expectedType,
322+ ...(mode === "queue" ? {} : { targetRunId: activeRunId }),
323+ },
324+ });
325+ }
172326 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
173327 );
174328 });
packages/client-runtime/src/operations/commands.ts+484−236
@@ -1,300 +1,548 @@
11 import {
22 CommandId,
3− ORCHESTRATION_WS_METHODS,
4− type ClientOrchestrationCommand,
3+ ORCHESTRATION_V2_WS_METHODS,
4+ WS_METHODS,
5+ type ChatAttachment,
6+ type MessageId,
7+ type ModelSelection,
8+ type OrchestrationV2Command,
9+ type OrchestrationV2CreationSource,
10+ type PlanId,
11+ type ProjectId,
12+ type ProjectScript,
13+ type ProviderApprovalDecision,
14+ type ProviderInteractionMode,
15+ type ProviderUserInputAnswers,
16+ type RunId,
17+ type RuntimeMode,
18+ type RuntimeRequestId,
19+ type ThreadId,
20+ type UploadChatAttachment,
521 } from "@t3tools/contracts";
622 import * as Crypto from "effect/Crypto";
7−import * as DateTime from "effect/DateTime";
823 import * as Effect from "effect/Effect";
924
10−import type { EnvironmentSupervisor } from "../connection/supervisor.ts";
11−import {
12− type EnvironmentRpcFailure,
13− type EnvironmentRpcSuccess,
14− type EnvironmentRpcUnavailableError,
15− request,
16−} from "../rpc/client.ts";
17−
18−type CommandType = ClientOrchestrationCommand["type"];
19−type CommandOf<T extends CommandType> = Extract<ClientOrchestrationCommand, { readonly type: T }>;
20−type CommandInput<T extends CommandType> = Omit<
21− CommandOf<T>,
22− "type" | "commandId" | "createdAt"
23−> & {
24− readonly commandId?: CommandId;
25−} & ("createdAt" extends keyof CommandOf<T>
26− ? {
27− readonly createdAt?: CommandOf<T>["createdAt"];
28− }
29− : {});
30−
31−export type CreateProjectInput = CommandInput<"project.create">;
32−export type UpdateProjectInput = CommandInput<"project.meta.update">;
33−export type DeleteProjectInput = CommandInput<"project.delete">;
34−export type CreateThreadInput = CommandInput<"thread.create">;
35−export type DeleteThreadInput = CommandInput<"thread.delete">;
36−export type ArchiveThreadInput = CommandInput<"thread.archive">;
37−export type UnarchiveThreadInput = CommandInput<"thread.unarchive">;
38−export type SettleThreadInput = CommandInput<"thread.settle">;
39−export type UnsettleThreadInput = CommandInput<"thread.unsettle">;
40−export type SnoozeThreadInput = CommandInput<"thread.snooze">;
41−export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">;
42−export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">;
43−export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">;
44−export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">;
45−export type StartThreadTurnInput = CommandInput<"thread.turn.start">;
46−export type InterruptThreadTurnInput = CommandInput<"thread.turn.interrupt">;
47−export type RespondToThreadApprovalInput = CommandInput<"thread.approval.respond">;
48−export type RespondToThreadUserInputInput = CommandInput<"thread.user-input.respond">;
49−export type RevertThreadCheckpointInput = CommandInput<"thread.checkpoint.revert">;
50−export type StopThreadSessionInput = CommandInput<"thread.session.stop">;
51−
52−type DispatchTag = typeof ORCHESTRATION_WS_METHODS.dispatchCommand;
53−type CommandEffect = Effect.Effect<
54− EnvironmentRpcSuccess<DispatchTag>,
55− EnvironmentRpcFailure<DispatchTag> | EnvironmentRpcUnavailableError,
56− Crypto.Crypto | EnvironmentSupervisor
57−>;
58−
59−function commandId(input: { readonly commandId?: CommandId }) {
60− return Effect.gen(function* () {
61− if (input.commandId !== undefined) {
62− return input.commandId;
63− }
64− const crypto = yield* Crypto.Crypto;
65− return yield* crypto.randomUUIDv4.pipe(Effect.orDie, Effect.map(CommandId.make));
66− });
67−}
25+import { request } from "../rpc/client.ts";
6826
69−function timestampedCommandMetadata(input: {
27+interface CommandMetadata {
7028 readonly commandId?: CommandId;
7129 readonly createdAt?: string;
72−}) {
73− return Effect.all({
74− commandId: commandId(input),
75− createdAt:
76− input.createdAt === undefined
77− ? DateTime.now.pipe(Effect.map(DateTime.formatIso))
78− : Effect.succeed(input.createdAt),
79− });
30+ readonly creationSource?: OrchestrationV2CreationSource;
8031 }
8132
82−function dispatch(command: ClientOrchestrationCommand) {
83− return request(ORCHESTRATION_WS_METHODS.dispatchCommand, command);
33+export interface CreateProjectInput extends CommandMetadata {
34+ readonly projectId: ProjectId;
35+ readonly title: string;
36+ readonly workspaceRoot: string;
37+ readonly createWorkspaceRootIfMissing?: boolean;
38+ readonly defaultModelSelection?: ModelSelection | null;
39+ readonly scripts?: ReadonlyArray<ProjectScript>;
8440 }
8541
86−export const createProject: (input: CreateProjectInput) => CommandEffect = Effect.fn(
87− "EnvironmentCommands.createProject",
88−)(function* (input) {
89− const metadata = yield* timestampedCommandMetadata(input);
90− return yield* dispatch({
91− ...input,
92− type: "project.create",
93− commandId: metadata.commandId,
94− createdAt: metadata.createdAt,
95− });
96−});
42+export interface UpdateProjectInput extends CommandMetadata {
43+ readonly projectId: ProjectId;
44+ readonly title?: string;
45+ readonly workspaceRoot?: string;
46+ readonly defaultModelSelection?: ModelSelection | null;
47+ readonly scripts?: ReadonlyArray<ProjectScript>;
48+}
9749
98−export const updateProject: (input: UpdateProjectInput) => CommandEffect = Effect.fn(
99− "EnvironmentCommands.updateProject",
100−)(function* (input) {
101− return yield* dispatch({
102− ...input,
103− type: "project.meta.update",
104− commandId: yield* commandId(input),
105− });
106−});
50+export interface DeleteProjectInput extends CommandMetadata {
51+ readonly projectId: ProjectId;
52+ readonly force?: boolean;
53+}
10754
108−export const deleteProject: (input: DeleteProjectInput) => CommandEffect = Effect.fn(
109− "EnvironmentCommands.deleteProject",
110−)(function* (input) {
111− return yield* dispatch({
112− ...input,
113− type: "project.delete",
114− commandId: yield* commandId(input),
115− });
116−});
55+export interface CreateThreadInput extends CommandMetadata {
56+ readonly threadId: ThreadId;
57+ readonly projectId: ProjectId;
58+ readonly title: string;
59+ readonly modelSelection: ModelSelection;
60+ readonly runtimeMode: RuntimeMode;
61+ readonly interactionMode: ProviderInteractionMode;
62+ readonly branch: string | null;
63+ readonly worktreePath: string | null;
64+}
11765
118−export const createThread: (input: CreateThreadInput) => CommandEffect = Effect.fn(
119− "EnvironmentCommands.createThread",
120−)(function* (input) {
121− const metadata = yield* timestampedCommandMetadata(input);
122− return yield* dispatch({
123− ...input,
124− type: "thread.create",
125− commandId: metadata.commandId,
126− createdAt: metadata.createdAt,
127− });
66+export interface ThreadCommandInput extends CommandMetadata {
67+ readonly threadId: ThreadId;
68+}
69+
70+export type DeleteThreadInput = ThreadCommandInput;
71+export type ArchiveThreadInput = ThreadCommandInput;
72+export type UnarchiveThreadInput = ThreadCommandInput;
73+
74+export interface UpdateThreadMetadataInput extends ThreadCommandInput {
75+ readonly title?: string;
76+ readonly modelSelection?: ModelSelection;
77+ readonly branch?: string | null;
78+ readonly worktreePath?: string | null;
79+}
80+
81+export interface SetThreadRuntimeModeInput extends ThreadCommandInput {
82+ readonly runtimeMode: RuntimeMode;
83+}
84+
85+export interface SetThreadInteractionModeInput extends ThreadCommandInput {
86+ readonly interactionMode: ProviderInteractionMode;
87+}
88+
89+interface StartThreadBootstrap {
90+ readonly createThread?: {
91+ readonly projectId: ProjectId;
92+ readonly title: string;
93+ readonly modelSelection: ModelSelection;
94+ readonly runtimeMode: RuntimeMode;
95+ readonly interactionMode: ProviderInteractionMode;
96+ readonly branch: string | null;
97+ readonly worktreePath: string | null;
98+ readonly createdAt: string;
99+ };
100+ readonly prepareWorktree?: {
101+ readonly projectCwd: string;
102+ readonly baseBranch: string;
103+ readonly branch?: string;
104+ readonly startFromOrigin?: boolean;
105+ };
106+ readonly runSetupScript?: boolean;
107+}
108+
109+export interface StartThreadTurnInput extends ThreadCommandInput {
110+ readonly message: {
111+ readonly messageId: MessageId;
112+ readonly role: "user";
113+ readonly text: string;
114+ readonly attachments: ReadonlyArray<ChatAttachment | UploadChatAttachment>;
115+ };
116+ readonly modelSelection?: ModelSelection;
117+ readonly titleSeed?: string;
118+ readonly runtimeMode: RuntimeMode;
119+ readonly interactionMode: ProviderInteractionMode;
120+ readonly bootstrap?: StartThreadBootstrap;
121+ readonly sourceProposedPlan?: { readonly threadId: ThreadId; readonly planId: PlanId };
122+ readonly dispatchMode?: "auto" | "queue" | "steer" | "restart";
123+}
124+
125+export interface InterruptThreadTurnInput extends ThreadCommandInput {
126+ readonly runId?: RunId;
127+ /** Temporary caller compatibility while UI naming moves from turns to runs. */
128+ readonly turnId?: string;
129+}
130+
131+export interface RespondToThreadApprovalInput extends ThreadCommandInput {
132+ readonly requestId: RuntimeRequestId;
133+ readonly decision: ProviderApprovalDecision;
134+}
135+
136+export interface RespondToThreadUserInputInput extends ThreadCommandInput {
137+ readonly requestId: RuntimeRequestId;
138+ readonly answers: ProviderUserInputAnswers;
139+}
140+
141+export interface RevertThreadCheckpointInput extends ThreadCommandInput {
142+ readonly checkpointId?: string;
143+ readonly scopeId?: string;
144+ readonly turnCount?: number;
145+}
146+
147+export type StopThreadSessionInput = ThreadCommandInput;
148+
149+const allocateCommandId = Effect.fn("EnvironmentCommands.allocateCommandId")(function* (
150+ input: CommandMetadata,
151+) {
152+ if (input.commandId !== undefined) return input.commandId;
153+ const crypto = yield* Crypto.Crypto;
154+ return CommandId.make(yield* crypto.randomUUIDv4.pipe(Effect.orDie));
128155 });
129156
130−export const deleteThread: (input: DeleteThreadInput) => CommandEffect = Effect.fn(
131− "EnvironmentCommands.deleteThread",
132−)(function* (input) {
133− return yield* dispatch({
134− ...input,
135− type: "thread.delete",
136− commandId: yield* commandId(input),
157+const dispatch = (command: OrchestrationV2Command) =>
158+ request(ORCHESTRATION_V2_WS_METHODS.dispatchCommand, command);
159+
160+const getProjection = (threadId: ThreadId) =>
161+ request(ORCHESTRATION_V2_WS_METHODS.getThreadProjection, { threadId });
162+
163+const persistAttachments = Effect.fn("EnvironmentCommands.persistAttachments")(function* (
164+ threadId: ThreadId,
165+ messageId: MessageId,
166+ attachments: ReadonlyArray<ChatAttachment | UploadChatAttachment>,
167+) {
168+ const stored = attachments.filter(
169+ (attachment): attachment is ChatAttachment => "id" in attachment,
170+ );
171+ const uploads = attachments.filter(
172+ (attachment): attachment is UploadChatAttachment => "dataUrl" in attachment,
173+ );
174+ if (uploads.length === 0) return stored;
175+ const result = yield* request(WS_METHODS.assetsPersistChatAttachments, {
176+ threadId,
177+ messageId,
178+ attachments: uploads,
179+ });
180+ if (stored.length === 0) return result.attachments;
181+ const byUpload = new Map(
182+ uploads.map((attachment, index) => [attachment, result.attachments[index]]),
183+ );
184+ return attachments.flatMap((attachment) => {
185+ if ("id" in attachment) return [attachment];
186+ const persisted = byUpload.get(attachment);
187+ return persisted === undefined ? [] : [persisted];
137188 });
138189 });
139190
140−export const archiveThread: (input: ArchiveThreadInput) => CommandEffect = Effect.fn(
141− "EnvironmentCommands.archiveThread",
142−)(function* (input) {
143− return yield* dispatch({
144− ...input,
145− type: "thread.archive",
146− commandId: yield* commandId(input),
147− });
191+const mutateProject = Effect.fn("EnvironmentCommands.mutateProject")(function* (
192+ mutation:
193+ | {
194+ readonly type: "project.create";
195+ readonly commandId: CommandId;
196+ readonly projectId: ProjectId;
197+ readonly title: string;
198+ readonly workspaceRoot: string;
199+ readonly createWorkspaceRootIfMissing?: boolean;
200+ readonly defaultModelSelection?: ModelSelection | null;
201+ readonly scripts?: ReadonlyArray<ProjectScript>;
202+ }
203+ | {
204+ readonly type: "project.update";
205+ readonly commandId: CommandId;
206+ readonly projectId: ProjectId;
207+ readonly title?: string;
208+ readonly workspaceRoot?: string;
209+ readonly defaultModelSelection?: ModelSelection | null;
210+ readonly scripts?: ReadonlyArray<ProjectScript>;
211+ }
212+ | {
213+ readonly type: "project.delete";
214+ readonly commandId: CommandId;
215+ readonly projectId: ProjectId;
216+ readonly force?: boolean;
217+ },
218+) {
219+ return yield* request(WS_METHODS.projectsMutate, mutation);
148220 });
149221
150−export const unarchiveThread: (input: UnarchiveThreadInput) => CommandEffect = Effect.fn(
151− "EnvironmentCommands.unarchiveThread",
152−)(function* (input) {
153− return yield* dispatch({
154− ...input,
155− type: "thread.unarchive",
156− commandId: yield* commandId(input),
222+export const createProject = Effect.fn("EnvironmentCommands.createProject")(function* (
223+ input: CreateProjectInput,
224+) {
225+ return yield* mutateProject({
226+ type: "project.create",
227+ commandId: yield* allocateCommandId(input),
228+ projectId: input.projectId,
229+ title: input.title,
230+ workspaceRoot: input.workspaceRoot,
231+ ...(input.createWorkspaceRootIfMissing === undefined
232+ ? {}
233+ : { createWorkspaceRootIfMissing: input.createWorkspaceRootIfMissing }),
234+ ...(input.defaultModelSelection === undefined
235+ ? {}
236+ : { defaultModelSelection: input.defaultModelSelection }),
237+ ...(input.scripts === undefined ? {} : { scripts: input.scripts }),
157238 });
158239 });
159240
160−export const settleThread: (input: SettleThreadInput) => CommandEffect = Effect.fn(
161− "EnvironmentCommands.settleThread",
162−)(function* (input) {
163− return yield* dispatch({
164− ...input,
165− type: "thread.settle",
166− commandId: yield* commandId(input),
241+export const updateProject = Effect.fn("EnvironmentCommands.updateProject")(function* (
242+ input: UpdateProjectInput,
243+) {
244+ return yield* mutateProject({
245+ type: "project.update",
246+ commandId: yield* allocateCommandId(input),
247+ projectId: input.projectId,
248+ ...(input.title === undefined ? {} : { title: input.title }),
249+ ...(input.workspaceRoot === undefined ? {} : { workspaceRoot: input.workspaceRoot }),
250+ ...(input.defaultModelSelection === undefined
251+ ? {}
252+ : { defaultModelSelection: input.defaultModelSelection }),
253+ ...(input.scripts === undefined ? {} : { scripts: input.scripts }),
167254 });
168255 });
169256
170−export const unsettleThread: (input: UnsettleThreadInput) => CommandEffect = Effect.fn(
171− "EnvironmentCommands.unsettleThread",
172−)(function* (input) {
173− return yield* dispatch({
174− ...input,
175− type: "thread.unsettle",
176− commandId: yield* commandId(input),
257+export const deleteProject = Effect.fn("EnvironmentCommands.deleteProject")(function* (
258+ input: DeleteProjectInput,
259+) {
260+ return yield* mutateProject({
261+ type: "project.delete",
262+ commandId: yield* allocateCommandId(input),
263+ projectId: input.projectId,
264+ ...(input.force === undefined ? {} : { force: input.force }),
177265 });
178266 });
179267
180−export const snoozeThread: (input: SnoozeThreadInput) => CommandEffect = Effect.fn(
181− "EnvironmentCommands.snoozeThread",
182−)(function* (input) {
268+export const createThread = Effect.fn("EnvironmentCommands.createThread")(function* (
269+ input: CreateThreadInput,
270+) {
183271 return yield* dispatch({
184− ...input,
185− type: "thread.snooze",
186− commandId: yield* commandId(input),
272+ type: "thread.create",
273+ commandId: yield* allocateCommandId(input),
274+ createdBy: "user",
275+ creationSource: input.creationSource ?? "web",
276+ threadId: input.threadId,
277+ projectId: input.projectId,
278+ title: input.title,
279+ modelSelection: input.modelSelection,
280+ runtimeMode: input.runtimeMode,
281+ interactionMode: input.interactionMode,
282+ branch: input.branch,
283+ worktreePath: input.worktreePath,
187284 });
188285 });
189286
190−export const unsnoozeThread: (input: UnsnoozeThreadInput) => CommandEffect = Effect.fn(
191− "EnvironmentCommands.unsnoozeThread",
192−)(function* (input) {
193− return yield* dispatch({
194− ...input,
195− type: "thread.unsnooze",
196− commandId: yield* commandId(input),
197− });
287+function simpleThreadCommand(
288+ type: "thread.delete" | "thread.archive" | "thread.unarchive",
289+ input: ThreadCommandInput,
290+) {
291+ return allocateCommandId(input).pipe(
292+ Effect.flatMap((commandId) => dispatch({ type, commandId, threadId: input.threadId })),
293+ );
294+}
295+
296+export const deleteThread = Effect.fn("EnvironmentCommands.deleteThread")(function* (
297+ input: DeleteThreadInput,
298+) {
299+ return yield* simpleThreadCommand("thread.delete", input);
198300 });
199301
200−export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn(
201− "EnvironmentCommands.updateThreadMetadata",
202−)(function* (input) {
203− return yield* dispatch({
204− ...input,
205− type: "thread.meta.update",
206− commandId: yield* commandId(input),
207− });
302+export const archiveThread = Effect.fn("EnvironmentCommands.archiveThread")(function* (
303+ input: ArchiveThreadInput,
304+) {
305+ return yield* simpleThreadCommand("thread.archive", input);
208306 });
209307
210−export const setThreadRuntimeMode: (input: SetThreadRuntimeModeInput) => CommandEffect = Effect.fn(
211− "EnvironmentCommands.setThreadRuntimeMode",
212−)(function* (input) {
213− const metadata = yield* timestampedCommandMetadata(input);
214− return yield* dispatch({
215− ...input,
216− type: "thread.runtime-mode.set",
217− commandId: metadata.commandId,
218− createdAt: metadata.createdAt,
219− });
308+export const unarchiveThread = Effect.fn("EnvironmentCommands.unarchiveThread")(function* (
309+ input: UnarchiveThreadInput,
310+) {
311+ return yield* simpleThreadCommand("thread.unarchive", input);
220312 });
221313
222−export const setThreadInteractionMode: (input: SetThreadInteractionModeInput) => CommandEffect =
223− Effect.fn("EnvironmentCommands.setThreadInteractionMode")(function* (input) {
224− const metadata = yield* timestampedCommandMetadata(input);
314+export const updateThreadMetadata = Effect.fn("EnvironmentCommands.updateThreadMetadata")(
315+ function* (input: UpdateThreadMetadataInput) {
316+ const commandId = yield* allocateCommandId(input);
317+ let result = null;
318+ if (
319+ input.title !== undefined ||
320+ input.branch !== undefined ||
321+ input.worktreePath !== undefined
322+ ) {
323+ result = yield* dispatch({
324+ type: "thread.metadata.update",
325+ commandId,
326+ threadId: input.threadId,
327+ ...(input.title === undefined ? {} : { title: input.title }),
328+ ...(input.branch === undefined ? {} : { branch: input.branch }),
329+ ...(input.worktreePath === undefined ? {} : { worktreePath: input.worktreePath }),
330+ });
331+ }
332+ if (input.modelSelection !== undefined) {
333+ result = yield* dispatch({
334+ type: "thread.model-selection.set",
335+ commandId: result === null ? commandId : CommandId.make(`${commandId}:model-selection`),
336+ threadId: input.threadId,
337+ modelSelection: input.modelSelection,
338+ });
339+ }
340+ return result ?? { sequence: 0 };
341+ },
342+);
343+
344+export const setThreadRuntimeMode = Effect.fn("EnvironmentCommands.setThreadRuntimeMode")(
345+ function* (input: SetThreadRuntimeModeInput) {
346+ return yield* dispatch({
347+ type: "thread.runtime-mode.set",
348+ commandId: yield* allocateCommandId(input),
349+ threadId: input.threadId,
350+ runtimeMode: input.runtimeMode,
351+ });
352+ },
353+);
354+
355+export const setThreadInteractionMode = Effect.fn("EnvironmentCommands.setThreadInteractionMode")(
356+ function* (input: SetThreadInteractionModeInput) {
225357 return yield* dispatch({
226− ...input,
227358 type: "thread.interaction-mode.set",
228− commandId: metadata.commandId,
229− createdAt: metadata.createdAt,
359+ commandId: yield* allocateCommandId(input),
360+ threadId: input.threadId,
361+ interactionMode: input.interactionMode,
230362 });
231− });
363+ },
364+);
232365
233−export const startThreadTurn: (input: StartThreadTurnInput) => CommandEffect = Effect.fn(
234− "EnvironmentCommands.startThreadTurn",
235−)(function* (input) {
236− const metadata = yield* timestampedCommandMetadata(input);
366+export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")(function* (
367+ input: StartThreadTurnInput,
368+) {
369+ const commandId = yield* allocateCommandId(input);
370+ const attachments = yield* persistAttachments(
371+ input.threadId,
372+ input.message.messageId,
373+ input.message.attachments,
374+ );
375+ const bootstrap = input.bootstrap?.createThread;
376+ const prepareWorktree = input.bootstrap?.prepareWorktree;
377+ if (bootstrap !== undefined || prepareWorktree !== undefined) {
378+ const existingProjection =
379+ bootstrap === undefined ? yield* getProjection(input.threadId) : null;
380+ const thread = bootstrap ?? existingProjection!.thread;
381+ const workspaceStrategy =
382+ prepareWorktree !== undefined
383+ ? {
384+ type: "worktree" as const,
385+ baseRef: prepareWorktree.baseBranch,
386+ ...(prepareWorktree.branch === undefined ? {} : { branch: prepareWorktree.branch }),
387+ ...(prepareWorktree.startFromOrigin === undefined
388+ ? {}
389+ : { startFromOrigin: prepareWorktree.startFromOrigin }),
390+ }
391+ : bootstrap?.worktreePath
392+ ? {
393+ type: "existing_worktree" as const,
394+ worktreePath: bootstrap.worktreePath,
395+ ...(bootstrap.branch === null ? {} : { branch: bootstrap.branch }),
396+ }
397+ : {
398+ type: "root" as const,
399+ ...(bootstrap?.branch === null || bootstrap?.branch === undefined
400+ ? {}
401+ : { branch: bootstrap.branch }),
402+ };
403+ return yield* request(ORCHESTRATION_V2_WS_METHODS.launchThread, {
404+ commandId,
405+ creationSource: input.creationSource ?? "web",
406+ threadId: input.threadId,
407+ ...(bootstrap === undefined ? { reuseExistingThread: true } : {}),
408+ projectId: thread.projectId,
409+ title: thread.title,
410+ modelSelection: input.modelSelection ?? thread.modelSelection,
411+ runtimeMode: input.runtimeMode,
412+ interactionMode: input.interactionMode,
413+ workspaceStrategy,
414+ initialMessage: {
415+ messageId: input.message.messageId,
416+ text: input.message.text,
417+ attachments,
418+ },
419+ });
420+ }
421+
422+ const projection = yield* getProjection(input.threadId);
423+ const activeRun = projection.runs.findLast(
424+ (run) => run.status === "starting" || run.status === "running" || run.status === "waiting",
425+ );
426+ const requestedMode = input.dispatchMode ?? "auto";
427+ const activeProviderThread =
428+ activeRun === undefined
429+ ? undefined
430+ : projection.providerThreads.find((thread) => thread.id === activeRun.providerThreadId);
431+ const activeProviderSession =
432+ activeProviderThread?.providerSessionId === null ||
433+ activeProviderThread?.providerSessionId === undefined
434+ ? undefined
435+ : projection.providerSessions.find(
436+ (session) => session.id === activeProviderThread.providerSessionId,
437+ );
438+ const turnCapabilities = activeProviderSession?.capabilities.turns;
439+ const dispatchMode =
440+ activeRun === undefined
441+ ? ({ type: "start_immediately" } as const)
442+ : requestedMode === "steer"
443+ ? ({ type: "steer_active", targetRunId: activeRun.id } as const)
444+ : requestedMode === "restart"
445+ ? ({ type: "restart_active", targetRunId: activeRun.id } as const)
446+ : requestedMode === "queue"
447+ ? ({ type: "queue_after_active" } as const)
448+ : turnCapabilities?.supportsActiveSteering === true
449+ ? ({ type: "steer_active", targetRunId: activeRun.id } as const)
450+ : turnCapabilities?.supportsQueuedMessages === true
451+ ? ({ type: "queue_after_active" } as const)
452+ : turnCapabilities?.supportsSteeringByInterruptRestart === true
453+ ? ({ type: "restart_active", targetRunId: activeRun.id } as const)
454+ : ({ type: "queue_after_active" } as const);
237455 return yield* dispatch({
238− ...input,
239− type: "thread.turn.start",
240− commandId: metadata.commandId,
241− createdAt: metadata.createdAt,
456+ type: "message.dispatch",
457+ commandId,
458+ createdBy: "user",
459+ creationSource: input.creationSource ?? "web",
460+ threadId: input.threadId,
461+ messageId: input.message.messageId,
462+ text: input.message.text,
463+ attachments,
464+ ...(input.modelSelection === undefined ? {} : { modelSelection: input.modelSelection }),
465+ ...(input.sourceProposedPlan === undefined ? {} : { sourcePlanRef: input.sourceProposedPlan }),
466+ dispatchMode,
242467 });
243468 });
244469
245−export const interruptThreadTurn: (input: InterruptThreadTurnInput) => CommandEffect = Effect.fn(
246− "EnvironmentCommands.interruptThreadTurn",
247−)(function* (input) {
248− const metadata = yield* timestampedCommandMetadata(input);
470+export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThreadTurn")(function* (
471+ input: InterruptThreadTurnInput,
472+) {
473+ const projection = yield* getProjection(input.threadId);
474+ const runId =
475+ input.runId ??
476+ (input.turnId as RunId | undefined) ??
477+ projection.runs.findLast(
478+ (run) => run.status === "starting" || run.status === "running" || run.status === "waiting",
479+ )?.id;
480+ if (runId === undefined) return { sequence: 0 };
249481 return yield* dispatch({
250− ...input,
251− type: "thread.turn.interrupt",
252− commandId: metadata.commandId,
253− createdAt: metadata.createdAt,
482+ type: "run.interrupt",
483+ commandId: yield* allocateCommandId(input),
484+ threadId: input.threadId,
485+ runId,
254486 });
255487 });
256488
257−export const respondToThreadApproval: (input: RespondToThreadApprovalInput) => CommandEffect =
258− Effect.fn("EnvironmentCommands.respondToThreadApproval")(function* (input) {
259− const metadata = yield* timestampedCommandMetadata(input);
489+export const respondToThreadApproval = Effect.fn("EnvironmentCommands.respondToThreadApproval")(
490+ function* (input: RespondToThreadApprovalInput) {
260491 return yield* dispatch({
261− ...input,
262− type: "thread.approval.respond",
263− commandId: metadata.commandId,
264− createdAt: metadata.createdAt,
492+ type: "runtime-request.respond",
493+ commandId: yield* allocateCommandId(input),
494+ threadId: input.threadId,
495+ requestId: input.requestId,
496+ decision: input.decision,
265497 });
266− });
498+ },
499+);
267500
268−export const respondToThreadUserInput: (input: RespondToThreadUserInputInput) => CommandEffect =
269− Effect.fn("EnvironmentCommands.respondToThreadUserInput")(function* (input) {
270− const metadata = yield* timestampedCommandMetadata(input);
501+export const respondToThreadUserInput = Effect.fn("EnvironmentCommands.respondToThreadUserInput")(
502+ function* (input: RespondToThreadUserInputInput) {
271503 return yield* dispatch({
272− ...input,
273− type: "thread.user-input.respond",
274− commandId: metadata.commandId,
275− createdAt: metadata.createdAt,
504+ type: "runtime-request.respond",
505+ commandId: yield* allocateCommandId(input),
506+ threadId: input.threadId,
507+ requestId: input.requestId,
508+ answers: input.answers,
276509 });
277− });
510+ },
511+);
278512
279−export const revertThreadCheckpoint: (input: RevertThreadCheckpointInput) => CommandEffect =
280− Effect.fn("EnvironmentCommands.revertThreadCheckpoint")(function* (input) {
281− const metadata = yield* timestampedCommandMetadata(input);
513+export const revertThreadCheckpoint = Effect.fn("EnvironmentCommands.revertThreadCheckpoint")(
514+ function* (input: RevertThreadCheckpointInput) {
515+ const projection = yield* getProjection(input.threadId);
516+ const checkpoint =
517+ projection.checkpoints.find(
518+ (candidate) => candidate.id === input.checkpointId && candidate.scopeId === input.scopeId,
519+ ) ??
520+ projection.checkpoints.findLast((candidate) => candidate.appRunOrdinal === input.turnCount);
521+ if (checkpoint === undefined) return { sequence: 0 };
282522 return yield* dispatch({
283− ...input,
284− type: "thread.checkpoint.revert",
285− commandId: metadata.commandId,
286− createdAt: metadata.createdAt,
523+ type: "checkpoint.rollback",
524+ commandId: yield* allocateCommandId(input),
525+ threadId: input.threadId,
526+ scopeId: checkpoint.scopeId,
527+ checkpointId: checkpoint.id,
287528 });
288− });
529+ },
530+);
289531
290−export const stopThreadSession: (input: StopThreadSessionInput) => CommandEffect = Effect.fn(
291− "EnvironmentCommands.stopThreadSession",
292−)(function* (input) {
293− const metadata = yield* timestampedCommandMetadata(input);
294− return yield* dispatch({
295− ...input,
296− type: "thread.session.stop",
297− commandId: metadata.commandId,
298− createdAt: metadata.createdAt,
299− });
532+export const stopThreadSession = Effect.fn("EnvironmentCommands.stopThreadSession")(function* (
533+ input: StopThreadSessionInput,
534+) {
535+ const projection = yield* getProjection(input.threadId);
536+ const commandId = yield* allocateCommandId(input);
537+ let result = { sequence: 0 };
538+ for (const session of projection.providerSessions) {
539+ result = yield* dispatch({
540+ type: "provider-session.detach",
541+ commandId: CommandId.make(`${commandId}:detach:${session.id}`),
542+ threadId: input.threadId,
543+ providerSessionId: session.id,
544+ reason: "client-requested",
545+ });
546+ }
547+ return result;
300548 });
packages/client-runtime/src/platform/index.ts+1−0
@@ -1,4 +1,5 @@
11 export * from "./capabilities.ts";
22 export * from "./persistence.ts";
3+export * from "./orchestrationCache.ts";
34 export * from "./source.ts";
45 export * from "./storageDocument.ts";
packages/client-runtime/src/platform/orchestrationCache.test.ts+59−0
@@ -0,0 +1,59 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { EnvironmentId } from "@t3tools/contracts";
3+import * as Effect from "effect/Effect";
4+import * as Option from "effect/Option";
5+import * as Schema from "effect/Schema";
6+
7+import {
8+ ORCHESTRATION_CACHE_SCHEMA_VERSION,
9+ StoredOrchestrationShellSnapshot,
10+ StoredOrchestrationThreadSnapshot,
11+ decodeOrDiscardOrchestrationCache,
12+} from "./orchestrationCache.ts";
13+import { v2Projection, v2ShellSnapshot, v2ThreadId } from "../state/orchestrationV2TestFixtures.ts";
14+
15+const environmentId = EnvironmentId.make("environment-cache-test");
16+const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredOrchestrationShellSnapshot);
17+const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredOrchestrationThreadSnapshot);
18+
19+describe("orchestration cache envelopes", () => {
20+ it.effect("accepts V2 shell and thread cache envelopes", () =>
21+ Effect.gen(function* () {
22+ const shell = yield* decodeStoredShellSnapshot({
23+ schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
24+ environmentId,
25+ snapshot: v2ShellSnapshot,
26+ });
27+ const thread = yield* decodeStoredThreadSnapshot({
28+ schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
29+ environmentId,
30+ threadId: v2ThreadId,
31+ thread: v2Projection,
32+ });
33+
34+ expect(shell.snapshot).toEqual(v2ShellSnapshot);
35+ expect(thread.thread).toEqual(v2Projection);
36+ }),
37+ );
38+
39+ it.effect("discards V1-versioned cache envelopes after a decode failure", () =>
40+ Effect.gen(function* () {
41+ let discardCount = 0;
42+ const decoded = decodeStoredShellSnapshot({
43+ schemaVersion: 1,
44+ environmentId,
45+ snapshot: v2ShellSnapshot,
46+ }).pipe(Effect.map(Option.some));
47+
48+ const result = yield* decodeOrDiscardOrchestrationCache(
49+ decoded,
50+ Effect.sync(() => {
51+ discardCount += 1;
52+ }),
53+ );
54+
55+ expect(Option.isNone(result)).toBe(true);
56+ expect(discardCount).toBe(1);
57+ }),
58+ );
59+});
packages/client-runtime/src/platform/orchestrationCache.ts+32−0
@@ -0,0 +1,32 @@
1+import {
2+ EnvironmentId,
3+ OrchestrationV2ShellSnapshot,
4+ OrchestrationV2ThreadProjection,
5+ ThreadId,
6+} from "@t3tools/contracts";
7+import * as Effect from "effect/Effect";
8+import * as Option from "effect/Option";
9+import * as Schema from "effect/Schema";
10+
11+export const ORCHESTRATION_CACHE_SCHEMA_VERSION = 2 as const;
12+
13+export const StoredOrchestrationShellSnapshot = Schema.Struct({
14+ schemaVersion: Schema.Literal(ORCHESTRATION_CACHE_SCHEMA_VERSION),
15+ environmentId: EnvironmentId,
16+ snapshot: OrchestrationV2ShellSnapshot,
17+});
18+
19+export const StoredOrchestrationThreadSnapshot = Schema.Struct({
20+ schemaVersion: Schema.Literal(ORCHESTRATION_CACHE_SCHEMA_VERSION),
21+ environmentId: EnvironmentId,
22+ threadId: ThreadId,
23+ thread: OrchestrationV2ThreadProjection,
24+});
25+
26+/** Invalid orchestration caches are disposable and must never block live synchronization. */
27+export function decodeOrDiscardOrchestrationCache<A, E, R, E2, R2>(
28+ decode: Effect.Effect<Option.Option<A>, E, R>,
29+ discard: Effect.Effect<void, E2, R2>,
30+) {
31+ return decode.pipe(Effect.catch(() => discard.pipe(Effect.as(Option.none<A>()))));
32+}
packages/client-runtime/src/platform/persistence.ts+6−10
@@ -1,8 +1,7 @@
11 import {
22 type EnvironmentId,
3− type OrchestrationShellSnapshot,
4− type OrchestrationThreadDetailSnapshot,
5− type ServerConfig,
3+ type OrchestrationV2ShellSnapshot,
4+ type OrchestrationV2ThreadProjection,
65 type ThreadId,
76 type VcsListRefsResult,
87 } from "@t3tools/contracts";
@@ -60,21 +59,18 @@ export class EnvironmentCacheStore extends Context.Service< · 51 unmodified lines
6059 {
6160 readonly loadShell: (
6261 environmentId: EnvironmentId,
63− ) => Effect.Effect<Option.Option<OrchestrationShellSnapshot>, ConnectionPersistenceError>;
62+ ) => Effect.Effect<Option.Option<OrchestrationV2ShellSnapshot>, ConnectionPersistenceError>;
6463 readonly saveShell: (
6564 environmentId: EnvironmentId,
66− snapshot: OrchestrationShellSnapshot,
65+ snapshot: OrchestrationV2ShellSnapshot,
6766 ) => Effect.Effect<void, ConnectionPersistenceError>;
6867 readonly loadThread: (
6968 environmentId: EnvironmentId,
7069 threadId: ThreadId,
71− ) => Effect.Effect<
72− Option.Option<OrchestrationThreadDetailSnapshot>,
73− ConnectionPersistenceError
74− >;
70+ ) => Effect.Effect<Option.Option<OrchestrationV2ThreadProjection>, ConnectionPersistenceError>;
7571 readonly saveThread: (
7672 environmentId: EnvironmentId,
77− snapshot: OrchestrationThreadDetailSnapshot,
73+ thread: OrchestrationV2ThreadProjection,
7874 ) => Effect.Effect<void, ConnectionPersistenceError>;
7975 readonly removeThread: (
8076 environmentId: EnvironmentId,
packages/client-runtime/src/rpc/client.ts+3−7
@@ -1,8 +1,4 @@
1−import {
2− ORCHESTRATION_V2_WS_METHODS,
3− ORCHESTRATION_WS_METHODS,
4− WS_METHODS,
5−} from "@t3tools/contracts";
1+import { ORCHESTRATION_V2_WS_METHODS, WS_METHODS } from "@t3tools/contracts";
62 import * as Cause from "effect/Cause";
73 import * as Context from "effect/Context";
84 import type * as Duration from "effect/Duration";
@@ -44,8 +40,8 @@ export type EnvironmentRpcTag = keyof WsRpcProtocolClient & string; · 35 unmodified lines
4440 type RpcMethod<TTag extends EnvironmentRpcTag> = WsRpcProtocolClient[TTag];
4541
4642 export type EnvironmentSubscriptionRpcTag =
47− | typeof ORCHESTRATION_WS_METHODS.subscribeShell
48− | typeof ORCHESTRATION_WS_METHODS.subscribeThread
43+ | typeof ORCHESTRATION_V2_WS_METHODS.subscribeShell
44+ | typeof ORCHESTRATION_V2_WS_METHODS.subscribeThread
4945 | typeof WS_METHODS.subscribeAuthAccess
5046 | typeof WS_METHODS.subscribeServerConfig
5147 | typeof WS_METHODS.subscribeServerLifecycle
packages/client-runtime/src/state/archivedThreads.test.ts+2−2
@@ -1,4 +1,4 @@
1−import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts";
1+import { EnvironmentId, type OrchestrationV2ArchivedShellSnapshot } from "@t3tools/contracts";
22 import * as Cause from "effect/Cause";
33 import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
44 import { expect, it } from "vite-plus/test";
@@ -22,7 +22,7 @@ it("does not expose an archived snapshot failure message", () => { · 17 unmodified lines
2222 const snapshotsAtom = createArchivedThreadSnapshotsAtomFamily<Error>({
2323 getSnapshotAtom: () =>
2424 Atom.make(
25− AsyncResult.failure<OrchestrationShellSnapshot, Error>(
25+ AsyncResult.failure<OrchestrationV2ArchivedShellSnapshot, Error>(
2626 Cause.fail(new Error("credential=secret-value")),
2727 ),
2828 ),
packages/client-runtime/src/state/archivedThreads.ts+3−3
@@ -1,4 +1,4 @@
1−import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts";
1+import { EnvironmentId, type OrchestrationV2ArchivedShellSnapshot } from "@t3tools/contracts";
22 import * as Arr from "effect/Array";
33 import { pipe } from "effect/Function";
44 import * as Option from "effect/Option";
@@ -7,7 +7,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; · 2 unmodified lines
77
88 export interface ArchivedSnapshotEntry {
99 readonly environmentId: EnvironmentId;
10− readonly snapshot: OrchestrationShellSnapshot;
10+ readonly snapshot: OrchestrationV2ArchivedShellSnapshot;
1111 }
1212
1313 export interface ArchivedThreadSnapshotsState {
@@ -40,7 +40,7 @@ export function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray<E · 26 unmodified lines
4040 export function createArchivedThreadSnapshotsAtomFamily<E>(options: {
4141 readonly getSnapshotAtom: (
4242 environmentId: EnvironmentId,
43− ) => Atom.Atom<AsyncResult.AsyncResult<OrchestrationShellSnapshot, E>>;
43+ ) => Atom.Atom<AsyncResult.AsyncResult<OrchestrationV2ArchivedShellSnapshot, E>>;
4444 readonly labelPrefix: string;
4545 }) {
4646 return Atom.family((environmentKey: string) =>
packages/client-runtime/src/state/entities.test.ts+101−356
@@ -1,370 +1,115 @@
1−import {
2− EnvironmentId,
3− ProjectId,
4− ProviderInstanceId,
5− ThreadId,
6− type OrchestrationShellSnapshot,
7− type OrchestrationThread,
8−} from "@t3tools/contracts";
9−import { describe, expect, it } from "@effect/vitest";
10−import * as Option from "effect/Option";
11−import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
12−
13−import { PrimaryConnectionTarget } from "../connection/model.ts";
14−import {
15− InvalidScopedProjectKeyError,
16− InvalidScopedProjectRefCollectionKeyError,
17− InvalidScopedThreadKeyError,
18− parseProjectKey,
19− parseProjectRefCollectionKey,
20− parseThreadKey,
21−} from "./entities.ts";
22−import type { EnvironmentShellState } from "./shell.ts";
23−import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts";
24−import { createEnvironmentProjectAtoms } from "./projectEntities.ts";
25−import { createEnvironmentSnapshotAtom } from "./snapshots.ts";
26−import { createEnvironmentThreadDetailAtoms } from "./threadDetail.ts";
27−import { mergeEnvironmentThread } from "./threadDetail.ts";
28−import { createEnvironmentThreadShellAtoms } from "./threadShell.ts";
29−
30−const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
31−const PROJECT_ID = ProjectId.make("project-1");
32−const OTHER_PROJECT_ID = ProjectId.make("project-2");
33−const THREAD_ID = ThreadId.make("thread-1");
34−const OTHER_THREAD_ID = ThreadId.make("thread-2");
35−
36−describe("scoped entity keys", () => {
37− it("preserves an invalid project key as structured error data", () => {
38− const key = "missing-project-key-separator";
39− let error: unknown;
40−
41− try {
42− parseProjectKey(key);
43− } catch (cause) {
44− error = cause;
45− }
46−
47− expect(error).toEqual(new InvalidScopedProjectKeyError({ key }));
48− });
49−
50− it("preserves an invalid thread key as structured error data", () => {
51− const key = "missing-thread-key-separator";
52− let error: unknown;
53−
54− try {
55− parseThreadKey(key);
56− } catch (cause) {
57− error = cause;
58− }
59−
60− expect(error).toEqual(new InvalidScopedThreadKeyError({ key }));
61− });
62−
63− it("preserves malformed project reference collection input and its cause", () => {
64− const key = "not-json";
65− let error: unknown;
66−
67− try {
68− parseProjectRefCollectionKey(key);
69− } catch (cause) {
70− error = cause;
71− }
72−
73− expect(error).toBeInstanceOf(InvalidScopedProjectRefCollectionKeyError);
74− expect(error).toMatchObject({ key, cause: expect.anything() });
1+import { EnvironmentId, MessageId, RunId, TurnItemId } from "@t3tools/contracts";
2+import * as DateTime from "effect/DateTime";
3+import { describe, expect, it } from "vite-plus/test";
4+
5+import { presentThread, presentThreadShell } from "./models.ts";
6+import { v2Projection, v2ThreadShell } from "./orchestrationV2TestFixtures.ts";
7+
8+const environmentId = EnvironmentId.make("environment-v2");
9+
10+describe("V2 client presentation", () => {
11+ it("presents shell timestamps and status without constructing V1 state", () => {
12+ const shell = presentThreadShell(environmentId, v2ThreadShell);
13+ expect(shell.environmentId).toBe(environmentId);
14+ expect(shell.createdAt).toBe("2026-06-20T00:00:00.000Z");
15+ expect(shell.runtime).toBeNull();
16+ expect(shell.source).toBe(v2ThreadShell);
7517 });
7618
77− it("rejects invalid project reference collection shapes", () => {
78− const key = JSON.stringify([["environment-1"]]);
79−
80− expect(() => parseProjectRefCollectionKey(key)).toThrowError(
81− InvalidScopedProjectRefCollectionKeyError,
82− );
83− });
84−});
85−
86−const THREAD_SHELL = {
87− id: THREAD_ID,
88− projectId: PROJECT_ID,
89− title: "Thread",
90− modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
91− runtimeMode: "full-access",
92− interactionMode: "default",
93− branch: null,
94− worktreePath: null,
95− latestTurn: null,
96− createdAt: "2026-06-01T00:00:00.000Z",
97− updatedAt: "2026-06-01T00:00:00.000Z",
98− archivedAt: null,
99− settledOverride: null,
100− settledAt: null,
101− session: null,
102− latestUserMessageAt: null,
103− hasPendingApprovals: false,
104− hasPendingUserInput: false,
105− hasActionableProposedPlan: false,
106−} as const;
107−
108−const SNAPSHOT: OrchestrationShellSnapshot = {
109− snapshotSequence: 1,
110− updatedAt: "2026-06-01T00:00:00.000Z",
111− projects: [
112− {
113− id: PROJECT_ID,
114− title: "Project",
115− workspaceRoot: "/repo",
116− repositoryIdentity: null,
117− defaultModelSelection: null,
118− scripts: [],
119− createdAt: "2026-06-01T00:00:00.000Z",
120− updatedAt: "2026-06-01T00:00:00.000Z",
121− },
122− {
123− id: OTHER_PROJECT_ID,
124− title: "Other project",
125− workspaceRoot: "/other-repo",
126− repositoryIdentity: null,
127− defaultModelSelection: null,
128− scripts: [],
129− createdAt: "2026-06-01T00:00:00.000Z",
130− updatedAt: "2026-06-01T00:00:00.000Z",
131− },
132− ],
133− threads: [
134− THREAD_SHELL,
135− {
136− ...THREAD_SHELL,
137− id: OTHER_THREAD_ID,
138− projectId: OTHER_PROJECT_ID,
139− title: "Other thread",
140− },
141− ],
142−};
143−
144−function shellState(snapshot: OrchestrationShellSnapshot): EnvironmentShellState {
145− return {
146− snapshot: Option.some(snapshot),
147− status: "live",
148− error: Option.none(),
149− };
150−}
151−
152−function makeHarness() {
153− const shellStateAtoms = Atom.family((_environmentId: EnvironmentId) =>
154− Atom.make(AsyncResult.success(shellState(SNAPSHOT))),
155− );
156− const threadStateAtoms = Atom.family((_key: string) =>
157− Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)),
158− );
159− const catalogValueAtom = Atom.make({
160− isReady: true,
161− entries: new Map([
162− [
163− ENVIRONMENT_ID,
19+ it("retains the complete projection while deriving conversation parity", () => {
20+ const runId = RunId.make("run-1");
21+ const now = DateTime.makeUnsafe("2026-06-20T01:00:00.000Z");
22+ const projection = {
23+ ...v2Projection,
24+ runs: [
16425 {
165− target: new PrimaryConnectionTarget({
166− environmentId: ENVIRONMENT_ID,
167− label: "Environment",
168− httpBaseUrl: "https://example.test",
169− wsBaseUrl: "wss://example.test",
170− }),
171− profile: Option.none(),
26+ id: runId,
27+ threadId: v2Projection.thread.id,
28+ ordinal: 1,
29+ providerInstanceId: v2Projection.thread.providerInstanceId,
30+ modelSelection: v2Projection.thread.modelSelection,
31+ providerThreadId: null,
32+ userMessageId: MessageId.make("message-user"),
33+ rootNodeId: null,
34+ activeAttemptId: null,
35+ status: "running" as const,
36+ requestedAt: now,
37+ startedAt: now,
38+ completedAt: null,
39+ checkpointId: null,
40+ contextHandoffId: null,
17241 },
17342 ],
174− ]),
175− });
176− const snapshotAtom = createEnvironmentSnapshotAtom(shellStateAtoms);
177− const projects = createEnvironmentProjectAtoms({
178− catalogValueAtom,
179− snapshotAtom,
180− });
181− const threadShells = createEnvironmentThreadShellAtoms({
182− catalogValueAtom,
183− snapshotAtom,
184− });
185− const threadDetails = createEnvironmentThreadDetailAtoms((environmentId, threadId) =>
186− threadStateAtoms(`${environmentId}\u0000${threadId}`),
187− );
188−
189− return {
190− registry: AtomRegistry.make(),
191− shellStateAtom: shellStateAtoms(ENVIRONMENT_ID),
192− threadStateAtom: (threadId: ThreadId) => threadStateAtoms(`${ENVIRONMENT_ID}\u0000${threadId}`),
193− projects,
194− threadShells,
195− threadDetails,
196− };
197−}
198−
199−describe("environment entity projections", () => {
200− it("composes detail collections with authoritative shell workspace metadata", () => {
201− const messages: OrchestrationThread["messages"] = [];
202− const detail = {
203− ...THREAD_SHELL,
204− environmentId: ENVIRONMENT_ID,
205− title: "Cached thread",
206− branch: "stale-branch",
207− worktreePath: "/repo/stale-worktree",
208− deletedAt: null,
209− messages,
210− proposedPlans: [],
211− activities: [],
212− checkpoints: [],
213− } satisfies OrchestrationThread & { readonly environmentId: EnvironmentId };
214− const shell = {
215− ...THREAD_SHELL,
216− environmentId: ENVIRONMENT_ID,
217− title: "Current thread",
218− branch: "current-branch",
219− worktreePath: "/repo/current-worktree",
220− };
221−
222− const merged = mergeEnvironmentThread(detail, shell);
223−
224− expect(merged).toMatchObject({
225− title: "Current thread",
226− branch: "current-branch",
227− worktreePath: "/repo/current-worktree",
228− });
229− expect(merged?.messages).toBe(messages);
230− });
231−
232− it("preserves untouched project and thread identities across unrelated shell updates", () => {
233− const harness = makeHarness();
234− const projectRefsAtom = harness.projects.environmentProjectRefsAtom(ENVIRONMENT_ID);
235− const threadRefsAtom = harness.threadShells.environmentThreadRefsAtom(ENVIRONMENT_ID);
236− const projectsAtom = harness.projects.projectsAtom;
237− const projectAtom = harness.projects.projectAtom({
238− environmentId: ENVIRONMENT_ID,
239− projectId: PROJECT_ID,
240− });
241− const threadAtom = harness.threadShells.threadShellAtom({
242− environmentId: ENVIRONMENT_ID,
243− threadId: THREAD_ID,
244− });
245− const projectRefs = harness.registry.get(projectRefsAtom);
246− const threadRefs = harness.registry.get(threadRefsAtom);
247− const projects = harness.registry.get(projectsAtom);
248− const project = harness.registry.get(projectAtom);
249− const thread = harness.registry.get(threadAtom);
250−
251− harness.registry.set(
252− harness.shellStateAtom,
253− AsyncResult.success(
254− shellState({
255− ...SNAPSHOT,
256− snapshotSequence: 2,
257− threads: SNAPSHOT.threads.map((candidate) =>
258− candidate.id === OTHER_THREAD_ID
259− ? { ...candidate, title: "Renamed other thread" }
260− : candidate,
261− ),
262− }),
263− ),
264− );
265−
266− expect(harness.registry.get(projectRefsAtom)).toBe(projectRefs);
267− expect(harness.registry.get(threadRefsAtom)).toBe(threadRefs);
268− expect(harness.registry.get(projectsAtom)).toBe(projects);
269− expect(harness.registry.get(projectAtom)).toBe(project);
270− expect(harness.registry.get(threadAtom)).toBe(thread);
271− });
272−
273− it("preserves project-scoped thread collections across unrelated project updates", () => {
274− const harness = makeHarness();
275− const projectRef = {
276− environmentId: ENVIRONMENT_ID,
277− projectId: PROJECT_ID,
43+ messages: [
44+ {
45+ id: MessageId.make("message-user"),
46+ threadId: v2Projection.thread.id,
47+ runId,
48+ nodeId: null,
49+ role: "user" as const,
50+ text: "Hello",
51+ attachments: [],
52+ streaming: false,
53+ createdBy: "user" as const,
54+ creationSource: "web" as const,
55+ createdAt: now,
56+ updatedAt: now,
57+ },
58+ ],
59+ updatedAt: now,
27860 };
279− const refsByProjectAtom =
280− harness.threadShells.environmentThreadRefsByProjectAtom(ENVIRONMENT_ID);
281− const threadsAtom = harness.threadShells.threadShellsForProjectRefsAtom([projectRef]);
282− const refs = harness.registry.get(refsByProjectAtom).get(PROJECT_ID);
283− const threads = harness.registry.get(threadsAtom);
284−
285− expect(threads).toHaveLength(1);
286− expect(threads[0]?.id).toBe(THREAD_ID);
28761
288− harness.registry.set(
289− harness.shellStateAtom,
290− AsyncResult.success(
291− shellState({
292− ...SNAPSHOT,
293− snapshotSequence: 2,
294− threads: SNAPSHOT.threads.map((thread) =>
295− thread.id === OTHER_THREAD_ID ? { ...thread, title: "Updated elsewhere" } : thread,
296− ),
297− }),
298− ),
299− );
300−
301− expect(harness.registry.get(refsByProjectAtom).get(PROJECT_ID)).toBe(refs);
302− expect(harness.registry.get(threadsAtom)).toBe(threads);
62+ const thread = presentThread(environmentId, projection);
63+ expect(thread.projection).toBe(projection);
64+ expect(thread.latestRun?.runId).toBe(runId);
65+ expect(thread.runtime?.status).toBe("running");
66+ expect(thread.runs).toHaveLength(1);
67+ expect(thread.availableActions.canInterrupt).toBe(true);
68+ expect(thread.messages[0]).toMatchObject({ text: "Hello", runId });
30369 });
30470
305− it("updates only the requested thread detail and preserves untouched field identities", () => {
306− const harness = makeHarness();
307− const threadRef = {
308− environmentId: ENVIRONMENT_ID,
309− threadId: THREAD_ID,
71+ it("keeps the complete payload for generic V2 work-item rendering", () => {
72+ const now = DateTime.makeUnsafe("2026-06-20T01:00:00.000Z");
73+ const item = {
74+ id: TurnItemId.make("item-dynamic"),
75+ threadId: v2Projection.thread.id,
76+ runId: null,
77+ nodeId: null,
78+ providerThreadId: null,
79+ providerTurnId: null,
80+ nativeItemRef: null,
81+ parentItemId: null,
82+ ordinal: 0,
83+ status: "completed" as const,
84+ title: null,
85+ startedAt: now,
86+ completedAt: now,
87+ updatedAt: now,
88+ type: "dynamic_tool" as const,
89+ toolName: "custom_tool",
90+ input: { nested: { value: 1 } },
91+ output: { ok: true },
31092 };
311− const otherThreadRef = {
312− environmentId: ENVIRONMENT_ID,
313− threadId: OTHER_THREAD_ID,
93+ const projection = {
94+ ...v2Projection,
95+ turnItems: [item],
96+ visibleTurnItems: [
97+ {
98+ position: 0,
99+ visibility: "local" as const,
100+ sourceThreadId: v2Projection.thread.id,
101+ sourceItemId: item.id,
102+ item,
103+ },
104+ ],
105+ updatedAt: now,
314106 };
315− const threadDetailAtom = harness.threadDetails.detailAtom(threadRef);
316− const messagesAtom = harness.threadDetails.messagesAtom(threadRef);
317− const activitiesAtom = harness.threadDetails.activitiesAtom(threadRef);
318− const statusAtom = harness.threadDetails.statusAtom(threadRef);
319− const otherThreadDetailAtom = harness.threadDetails.detailAtom(otherThreadRef);
320− const otherValue = harness.registry.get(otherThreadDetailAtom);
321− const detail = {
322− ...THREAD_SHELL,
323− deletedAt: null,
324− messages: [],
325− proposedPlans: [],
326− activities: [],
327− checkpoints: [],
328− } satisfies OrchestrationThread;
329−
330− harness.registry.set(
331− harness.threadStateAtom(THREAD_ID),
332− AsyncResult.success<EnvironmentThreadState>({
333− data: Option.some(detail),
334− status: "live",
335− error: Option.none(),
336− }),
337− );
338−
339− const scopedDetail = harness.registry.get(threadDetailAtom);
340− const messages = harness.registry.get(messagesAtom);
341− const activities = harness.registry.get(activitiesAtom);
342107
343− expect(scopedDetail).toEqual({ ...detail, environmentId: ENVIRONMENT_ID });
344− expect(harness.registry.get(statusAtom)).toBe("live");
345− expect(harness.registry.get(otherThreadDetailAtom)).toBe(otherValue);
346−
347− harness.registry.set(
348− harness.threadStateAtom(THREAD_ID),
349− AsyncResult.success<EnvironmentThreadState>({
350− data: Option.some({
351− ...detail,
352− session: {
353− threadId: THREAD_ID,
354− status: "ready",
355− providerName: "codex",
356− runtimeMode: "full-access",
357− activeTurnId: null,
358− lastError: null,
359− updatedAt: "2026-06-01T00:01:00.000Z",
360− },
361− }),
362− status: "live",
363− error: Option.none(),
364− }),
365− );
366−
367− expect(harness.registry.get(messagesAtom)).toBe(messages);
368− expect(harness.registry.get(activitiesAtom)).toBe(activities);
108+ const thread = presentThread(environmentId, projection);
109+ expect(thread.workEntries[0]).toMatchObject({
110+ itemType: "dynamic_tool",
111+ label: "custom_tool",
112+ structuredPayload: item,
113+ });
369114 });
370115 });
packages/client-runtime/src/state/models.ts+705−15
@@ -1,25 +1,459 @@
11 import type {
2+ ChatAttachment,
3+ CheckpointId,
4+ CheckpointRef,
5+ CheckpointScopeId,
26 EnvironmentId,
3− OrchestrationMessage,
7+ MessageId,
48 OrchestrationProjectShell,
5− OrchestrationShellSnapshot,
6− OrchestrationThread,
7− OrchestrationThreadShell,
9+ OrchestrationV2PlanArtifact,
10+ OrchestrationV2ProviderCapabilities,
11+ OrchestrationV2RunStatus,
12+ OrchestrationV2ShellSnapshot,
13+ OrchestrationV2ThreadProjection,
14+ OrchestrationV2ThreadShell,
15+ OrchestrationV2TurnItem,
16+ PlanId,
17+ ProjectId,
18+ ProviderApprovalDecision,
19+ ProviderInstanceId,
20+ ProviderRequestKind,
21+ RunId,
22+ RuntimeRequestId,
823 ThreadId,
924 } from "@t3tools/contracts";
25+import * as DateTime from "effect/DateTime";
1026
1127 export interface EnvironmentProject extends OrchestrationProjectShell {
1228 readonly environmentId: EnvironmentId;
1329 }
1430
15−export interface EnvironmentThreadShell extends OrchestrationThreadShell {
31+/**
32+ * A pristine V2 thread projection paired with the environment that produced it.
33+ *
34+ * Keep the projection nested so its identity and every structurally shared
35+ * collection remain intact. This is the scoped detail boundary for rich V2
36+ * consumers; it is intentionally not another presentation-shaped thread.
37+ */
38+export interface ScopedThreadProjection {
1639 readonly environmentId: EnvironmentId;
40+ readonly projection: OrchestrationV2ThreadProjection;
1741 }
1842
19−export type EnvironmentMessage = OrchestrationMessage;
43+export interface ThreadRunSummary {
44+ readonly runId: RunId;
45+ readonly status: OrchestrationV2RunStatus;
46+ readonly requestedAt: string | null;
47+ readonly startedAt: string | null;
48+ readonly completedAt: string | null;
49+ readonly assistantMessageId: MessageId | null;
50+ readonly sourcePlanRef?: {
51+ readonly threadId: ThreadId;
52+ readonly planId: PlanId;
53+ };
54+}
55+
56+export interface ThreadRuntimeSummary {
57+ readonly status: OrchestrationV2RunStatus | "idle";
58+ readonly activeRunId: RunId | null;
59+ readonly providerInstanceId: ProviderInstanceId;
60+ readonly providerName: string | null;
61+ readonly lastError: string | null;
62+ readonly updatedAt: string;
63+}
64+
65+export function threadRuntimeIsActive(runtime: ThreadRuntimeSummary | null | undefined): boolean {
66+ return runtime !== null && runtime !== undefined && threadRunStatusIsActive(runtime.status);
67+}
68+
69+export function threadRunStatusIsActive(status: ThreadRuntimeSummary["status"]): boolean {
70+ return (
71+ status === "queued" || status === "starting" || status === "running" || status === "waiting"
72+ );
73+}
74+
75+export interface ThreadConversationMessage {
76+ readonly id: MessageId;
77+ readonly role: "user" | "assistant" | "system";
78+ readonly text: string;
79+ readonly attachments?: ReadonlyArray<ChatAttachment>;
80+ readonly runId: RunId | null;
81+ readonly streaming: boolean;
82+ readonly createdAt: string;
83+ readonly updatedAt: string;
84+}
85+
86+export interface ThreadProposedPlan {
87+ readonly id: PlanId;
88+ readonly runId: RunId | null;
89+ readonly planMarkdown: string;
90+ readonly status: OrchestrationV2PlanArtifact["status"];
91+ readonly implementedAt: string | null;
92+ readonly implementationThreadId: ThreadId | null;
93+ readonly createdAt: string;
94+ readonly updatedAt: string;
95+}
96+
97+export interface ThreadTodoPlan {
98+ readonly id: PlanId;
99+ readonly runId: RunId | null;
100+ readonly status: OrchestrationV2PlanArtifact["status"];
101+ readonly explanation: string | null;
102+ readonly steps: ReadonlyArray<{
103+ readonly id: string;
104+ readonly step: string;
105+ readonly status: "pending" | "inProgress" | "completed";
106+ }>;
107+ readonly updatedAt: string;
108+}
109+
110+export interface ThreadCheckpointSummary {
111+ readonly checkpointId?: CheckpointId;
112+ readonly scopeId?: CheckpointScopeId;
113+ readonly runId: RunId;
114+ readonly checkpointTurnCount: number;
115+ readonly checkpointRef: CheckpointRef;
116+ readonly status: "ready" | "missing" | "error" | "stale";
117+ readonly files: ReadonlyArray<{
118+ readonly path: string;
119+ readonly kind: string;
120+ readonly additions: number;
121+ readonly deletions: number;
122+ }>;
123+ readonly assistantMessageId: MessageId | null;
124+ readonly completedAt: string;
125+}
20126
21−export interface EnvironmentThread extends OrchestrationThread {
127+export interface ThreadPendingApproval {
128+ readonly requestId: RuntimeRequestId;
129+ readonly requestKind: ProviderRequestKind;
130+ readonly createdAt: string;
131+ readonly detail?: string;
132+ readonly responseCapability: "live" | "not_resumable";
133+}
134+
135+export interface ThreadUserInputQuestion {
136+ readonly id: string;
137+ readonly header: string;
138+ readonly question: string;
139+ readonly options: ReadonlyArray<{
140+ readonly label: string;
141+ readonly description: string;
142+ }>;
143+ readonly multiSelect: boolean;
144+}
145+
146+export interface ThreadPendingUserInput {
147+ readonly requestId: RuntimeRequestId;
148+ readonly createdAt: string;
149+ readonly questions: ReadonlyArray<ThreadUserInputQuestion>;
150+ readonly responseCapability: "live" | "not_resumable";
151+}
152+
153+export type ThreadWorkEntryTone = "thinking" | "tool" | "info" | "error";
154+export type ThreadWorkEntryStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped";
155+
156+/**
157+ * Stable parity presentation for a V2 turn item. `structuredPayload` always
158+ * retains the complete source item so generic rendering never loses provider
159+ * or tool data.
160+ */
161+export interface ThreadWorkEntry {
162+ readonly id: string;
163+ readonly createdAt: string;
164+ readonly runId: RunId | null;
165+ readonly label: string;
166+ readonly detail?: string;
167+ readonly command?: string;
168+ readonly rawCommand?: string;
169+ readonly changedFiles?: ReadonlyArray<string>;
170+ readonly tone: ThreadWorkEntryTone;
171+ readonly toolTitle?: string;
172+ readonly toolData?: unknown;
173+ readonly itemType: OrchestrationV2TurnItem["type"];
174+ readonly requestKind?: ProviderRequestKind;
175+ readonly toolLifecycleStatus: ThreadWorkEntryStatus;
176+ readonly structuredPayload: OrchestrationV2TurnItem;
177+}
178+
179+export interface EnvironmentThreadShell {
22180 readonly environmentId: EnvironmentId;
181+ readonly id: ThreadId;
182+ readonly projectId: ProjectId;
183+ readonly title: string;
184+ readonly providerInstanceId: ProviderInstanceId;
185+ readonly modelSelection: OrchestrationV2ThreadShell["modelSelection"];
186+ readonly runtimeMode: OrchestrationV2ThreadShell["runtimeMode"];
187+ readonly interactionMode: OrchestrationV2ThreadShell["interactionMode"];
188+ readonly branch: string | null;
189+ readonly worktreePath: string | null;
190+ readonly lineage: OrchestrationV2ThreadShell["lineage"];
191+ readonly forkedFrom: OrchestrationV2ThreadShell["forkedFrom"];
192+ readonly activeProviderThreadId: OrchestrationV2ThreadShell["activeProviderThreadId"];
193+ readonly latestRun: ThreadRunSummary | null;
194+ readonly runtime: ThreadRuntimeSummary | null;
195+ readonly latestUserMessageAt: string | null;
196+ readonly hasPendingApprovals: boolean;
197+ readonly hasPendingUserInput: boolean;
198+ readonly hasActionableProposedPlan: boolean;
199+ readonly itemCount: number;
200+ readonly visibleItemCount: number;
201+ readonly createdAt: string;
202+ readonly updatedAt: string;
203+ readonly archivedAt: string | null;
204+ readonly deletedAt: string | null;
205+ readonly source: OrchestrationV2ThreadShell;
206+}
207+
208+export interface EnvironmentThread extends EnvironmentThreadShell {
209+ readonly projection: OrchestrationV2ThreadProjection;
210+ readonly runs: ReadonlyArray<ThreadRunSummary>;
211+ readonly capabilities: OrchestrationV2ProviderCapabilities | null;
212+ readonly availableActions: {
213+ readonly canSendMessage: boolean;
214+ readonly canInterrupt: boolean;
215+ readonly canRespondToApproval: boolean;
216+ readonly canRespondToUserInput: boolean;
217+ readonly canRollback: boolean;
218+ readonly canArchive: boolean;
219+ readonly canUnarchive: boolean;
220+ readonly canDelete: boolean;
221+ readonly canUpdateSettings: boolean;
222+ };
223+ readonly messages: ReadonlyArray<ThreadConversationMessage>;
224+ readonly workEntries: ReadonlyArray<ThreadWorkEntry>;
225+ readonly proposedPlans: ReadonlyArray<ThreadProposedPlan>;
226+ readonly todoPlans: ReadonlyArray<ThreadTodoPlan>;
227+ readonly pendingApprovals: ReadonlyArray<ThreadPendingApproval>;
228+ readonly pendingUserInputs: ReadonlyArray<ThreadPendingUserInput>;
229+ readonly checkpoints: ReadonlyArray<ThreadCheckpointSummary>;
230+}
231+
232+function iso(value: DateTime.Utc): string {
233+ return DateTime.formatIso(value);
234+}
235+
236+function nullableIso(value: DateTime.Utc | null): string | null {
237+ return value === null ? null : iso(value);
238+}
239+
240+function terminalRunStatus(status: OrchestrationV2RunStatus): boolean {
241+ return (
242+ status === "completed" ||
243+ status === "interrupted" ||
244+ status === "failed" ||
245+ status === "cancelled" ||
246+ status === "rolled_back"
247+ );
248+}
249+
250+function workEntryStatus(item: OrchestrationV2TurnItem): ThreadWorkEntryStatus {
251+ switch (item.status) {
252+ case "pending":
253+ case "running":
254+ case "waiting":
255+ return "inProgress";
256+ case "completed":
257+ return "completed";
258+ case "failed":
259+ return "failed";
260+ case "cancelled":
261+ case "interrupted":
262+ return "stopped";
263+ }
264+}
265+
266+function workEntryTone(item: OrchestrationV2TurnItem): ThreadWorkEntryTone {
267+ if (item.status === "failed") return "error";
268+ if (item.type === "reasoning") return "thinking";
269+ switch (item.type) {
270+ case "command_execution":
271+ case "file_change":
272+ case "file_search":
273+ case "web_search":
274+ case "dynamic_tool":
275+ case "subagent":
276+ return "tool";
277+ default:
278+ return "info";
279+ }
280+}
281+
282+function summarizeWorkItem(item: OrchestrationV2TurnItem): {
283+ readonly label: string;
284+ readonly detail?: string;
285+ readonly command?: string;
286+ readonly rawCommand?: string;
287+ readonly changedFiles?: ReadonlyArray<string>;
288+ readonly toolTitle?: string;
289+ readonly toolData?: unknown;
290+ readonly requestKind?: ProviderRequestKind;
291+} {
292+ const title = item.title?.trim() || null;
293+ switch (item.type) {
294+ case "reasoning":
295+ return { label: title ?? "Thinking", ...(item.text ? { detail: item.text } : {}) };
296+ case "command_execution":
297+ return {
298+ label: title ?? "Ran command",
299+ command: item.input,
300+ rawCommand: item.input,
301+ ...(item.output ? { detail: item.output } : {}),
302+ toolTitle: title ?? "Command",
303+ toolData: item,
304+ };
305+ case "file_change":
306+ return {
307+ label: title ?? `Changed ${item.fileName}`,
308+ changedFiles: [item.fileName],
309+ ...(item.diffStr ? { detail: item.diffStr } : {}),
310+ toolTitle: title ?? "File change",
311+ toolData: item,
312+ };
313+ case "file_search":
314+ return {
315+ label: title ?? "Searched files",
316+ ...(item.pattern ? { detail: item.pattern } : {}),
317+ toolTitle: title ?? "File search",
318+ toolData: item,
319+ };
320+ case "web_search":
321+ return {
322+ label: title ?? "Searched the web",
323+ ...(item.patterns?.length ? { detail: item.patterns.join(", ") } : {}),
324+ toolTitle: title ?? "Web search",
325+ toolData: item,
326+ };
327+ case "approval_request":
328+ return {
329+ label: title ?? "Approval requested",
330+ ...(item.prompt ? { detail: item.prompt } : {}),
331+ requestKind: item.requestKind,
332+ toolData: item,
333+ };
334+ case "user_input_request":
335+ return {
336+ label: title ?? "Input requested",
337+ detail: item.questions.map((question) => question.question).join("\n"),
338+ toolData: item,
339+ };
340+ case "checkpoint":
341+ return {
342+ label: title ?? "Checkpoint captured",
343+ changedFiles: item.files.map((file) => file.path),
344+ toolData: item,
345+ };
346+ case "run_interrupt_request":
347+ return { label: title ?? "Interrupt requested", detail: item.message, toolData: item };
348+ case "run_interrupt_result":
349+ return { label: title ?? "Run interrupted", detail: item.message, toolData: item };
350+ case "compaction":
351+ return {
352+ label: title ?? "Context compacted",
353+ ...(item.summary ? { detail: item.summary } : {}),
354+ toolData: item,
355+ };
356+ case "handoff":
357+ return {
358+ label: title ?? "Context handed off",
359+ ...(item.summary ? { detail: item.summary } : {}),
360+ toolData: item,
361+ };
362+ case "fork":
363+ return { label: title ?? "Thread forked", detail: item.targetThreadId, toolData: item };
364+ case "subagent":
365+ return {
366+ label: title ?? "Subagent",
367+ detail: item.result ?? item.prompt,
368+ toolTitle: title ?? "Subagent",
369+ toolData: item,
370+ };
371+ case "dynamic_tool":
372+ return {
373+ label: title ?? item.toolName ?? "Tool call",
374+ toolTitle: title ?? item.toolName ?? "Tool",
375+ toolData: { input: item.input, output: item.output },
376+ };
377+ case "user_message":
378+ return { label: title ?? "User message", detail: item.text, toolData: item };
379+ case "assistant_message":
380+ return { label: title ?? "Assistant message", detail: item.text, toolData: item };
381+ case "proposed_plan":
382+ return { label: title ?? "Proposed plan", detail: item.markdown, toolData: item };
383+ case "todo_list":
384+ return {
385+ label: title ?? "Plan updated",
386+ detail: item.steps.map((step) => step.text).join("\n"),
387+ toolData: item,
388+ };
389+ }
390+}
391+
392+function presentWorkEntries(
393+ projection: OrchestrationV2ThreadProjection,
394+): ReadonlyArray<ThreadWorkEntry> {
395+ return projection.visibleTurnItems
396+ .filter(
397+ ({ item }) =>
398+ item.type !== "user_message" &&
399+ item.type !== "assistant_message" &&
400+ item.type !== "proposed_plan" &&
401+ item.type !== "todo_list",
402+ )
403+ .map(({ item }) => {
404+ const summary = summarizeWorkItem(item);
405+ return {
406+ id: item.id,
407+ createdAt: iso(item.startedAt ?? item.updatedAt),
408+ runId: item.runId,
409+ ...summary,
410+ tone: workEntryTone(item),
411+ itemType: item.type,
412+ toolLifecycleStatus: workEntryStatus(item),
413+ structuredPayload: item,
414+ } satisfies ThreadWorkEntry;
415+ });
416+}
417+
418+function presentRuns(projection: OrchestrationV2ThreadProjection): ReadonlyArray<ThreadRunSummary> {
419+ return projection.runs
420+ .toSorted((left, right) => left.ordinal - right.ordinal)
421+ .map((run) => ({
422+ runId: run.id,
423+ status: run.status,
424+ requestedAt: iso(run.requestedAt),
425+ startedAt: nullableIso(run.startedAt),
426+ completedAt: nullableIso(run.completedAt),
427+ assistantMessageId:
428+ projection.messages.findLast(
429+ (message) => message.runId === run.id && message.role === "assistant",
430+ )?.id ?? null,
431+ ...(run.sourcePlanRef === undefined ? {} : { sourcePlanRef: run.sourcePlanRef }),
432+ }));
433+}
434+
435+function presentLatestRun(projection: OrchestrationV2ThreadProjection): ThreadRunSummary | null {
436+ return presentRuns(projection).at(-1) ?? null;
437+}
438+
439+function presentRuntime(
440+ shell: OrchestrationV2ThreadShell,
441+ projection?: OrchestrationV2ThreadProjection,
442+): ThreadRuntimeSummary | null {
443+ const latestRun = projection === undefined ? null : presentLatestRun(projection);
444+ const hasRuntime = shell.latestRunId !== null || shell.activeProviderThreadId !== null;
445+ if (!hasRuntime && latestRun === null) return null;
446+ const providerSession = projection?.providerSessions.findLast(
447+ (session) => session.providerInstanceId === shell.providerInstanceId,
448+ );
449+ return {
450+ status: latestRun?.status ?? shell.status,
451+ activeRunId: shell.activeRunId,
452+ providerInstanceId: shell.providerInstanceId,
453+ providerName: providerSession?.driver ?? null,
454+ lastError: providerSession?.lastError ?? null,
455+ updatedAt: iso(projection?.updatedAt ?? shell.updatedAt),
456+ };
23457 }
24458
25459 export function scopeProject(
@@ -29,25 +463,281 @@ export function scopeProject( · 3 unmodified lines
29463 return { ...project, environmentId };
30464 }
31465
32−export function scopeThreadShell(
466+export function presentThreadShell(
33467 environmentId: EnvironmentId,
34− thread: OrchestrationThreadShell,
468+ thread: OrchestrationV2ThreadShell,
35469 ): EnvironmentThreadShell {
36− return { ...thread, environmentId };
470+ const updatedAt = iso(thread.updatedAt);
471+ const latestRun =
472+ thread.latestRunId === null
473+ ? null
474+ : ({
475+ runId: thread.latestRunId,
476+ status: thread.status === "idle" ? "completed" : thread.status,
477+ requestedAt: null,
478+ startedAt: null,
479+ completedAt:
480+ thread.status === "idle" || terminalRunStatus(thread.status) ? updatedAt : null,
481+ assistantMessageId: null,
482+ } satisfies ThreadRunSummary);
483+ return {
484+ environmentId,
485+ id: thread.id,
486+ projectId: thread.projectId,
487+ title: thread.title,
488+ providerInstanceId: thread.providerInstanceId,
489+ modelSelection: thread.modelSelection,
490+ runtimeMode: thread.runtimeMode,
491+ interactionMode: thread.interactionMode,
492+ branch: thread.branch,
493+ worktreePath: thread.worktreePath,
494+ lineage: thread.lineage,
495+ forkedFrom: thread.forkedFrom,
496+ activeProviderThreadId: thread.activeProviderThreadId,
497+ latestRun,
498+ runtime: presentRuntime(thread),
499+ latestUserMessageAt: nullableIso(thread.latestUserMessageAt),
500+ hasPendingApprovals:
501+ thread.pendingRuntimeRequest !== null &&
502+ thread.pendingRuntimeRequest.kind !== "user_input" &&
503+ thread.pendingRuntimeRequest.kind !== "auth_refresh",
504+ hasPendingUserInput: thread.pendingRuntimeRequest?.kind === "user_input",
505+ hasActionableProposedPlan: thread.hasActionableProposedPlan,
506+ itemCount: thread.itemCount,
507+ visibleItemCount: thread.visibleItemCount,
508+ createdAt: iso(thread.createdAt),
509+ updatedAt,
510+ archivedAt: nullableIso(thread.archivedAt),
511+ deletedAt: nullableIso(thread.deletedAt),
512+ source: thread,
513+ };
37514 }
38515
39−export function scopeThread(
516+export const scopeThreadShell = presentThreadShell;
517+
518+function presentMessages(
519+ projection: OrchestrationV2ThreadProjection,
520+): ReadonlyArray<ThreadConversationMessage> {
521+ return projection.messages.map((message) => ({
522+ id: message.id,
523+ role: message.role,
524+ text: message.text,
525+ attachments: message.attachments,
526+ runId: message.runId,
527+ streaming: message.streaming,
528+ createdAt: iso(message.createdAt),
529+ updatedAt: iso(message.updatedAt),
530+ }));
531+}
532+
533+function planItemTime(projection: OrchestrationV2ThreadProjection, planId: PlanId): string {
534+ const item = projection.turnItems.findLast(
535+ (candidate) =>
536+ (candidate.type === "proposed_plan" || candidate.type === "todo_list") &&
537+ candidate.planId === planId,
538+ );
539+ return iso(item?.updatedAt ?? projection.updatedAt);
540+}
541+
542+function presentProposedPlans(
543+ projection: OrchestrationV2ThreadProjection,
544+): ReadonlyArray<ThreadProposedPlan> {
545+ return projection.plans.flatMap((plan) => {
546+ if (plan.kind !== "proposed_plan") return [];
547+ const updatedAt = planItemTime(projection, plan.id);
548+ return [
549+ {
550+ id: plan.id,
551+ runId: plan.runId,
552+ planMarkdown: plan.markdown,
553+ status: plan.status,
554+ implementedAt: plan.status === "completed" ? updatedAt : null,
555+ implementationThreadId: null,
556+ createdAt: updatedAt,
557+ updatedAt,
558+ } satisfies ThreadProposedPlan,
559+ ];
560+ });
561+}
562+
563+function presentTodoPlans(
564+ projection: OrchestrationV2ThreadProjection,
565+): ReadonlyArray<ThreadTodoPlan> {
566+ return projection.plans.flatMap((plan) => {
567+ if (plan.kind !== "todo_list") return [];
568+ return [
569+ {
570+ id: plan.id,
571+ runId: plan.runId,
572+ status: plan.status,
573+ explanation: plan.explanation ?? null,
574+ steps: plan.steps.map((step) => ({
575+ id: step.id,
576+ step: step.text,
577+ status: step.status === "running" ? "inProgress" : step.status,
578+ })),
579+ updatedAt: planItemTime(projection, plan.id),
580+ } satisfies ThreadTodoPlan,
581+ ];
582+ });
583+}
584+
585+function presentPendingRequests(projection: OrchestrationV2ThreadProjection): {
586+ readonly approvals: ReadonlyArray<ThreadPendingApproval>;
587+ readonly userInputs: ReadonlyArray<ThreadPendingUserInput>;
588+} {
589+ const approvals: ThreadPendingApproval[] = [];
590+ const userInputs: ThreadPendingUserInput[] = [];
591+ for (const request of projection.runtimeRequests) {
592+ if (request.status !== "pending") continue;
593+ const responseCapability = request.responseCapability.type;
594+ if (request.kind === "user_input") {
595+ const item = projection.turnItems.findLast(
596+ (candidate) =>
597+ candidate.type === "user_input_request" && candidate.requestId === request.id,
598+ );
599+ if (item === undefined || item.type !== "user_input_request") continue;
600+ userInputs.push({
601+ requestId: request.id,
602+ createdAt: iso(request.createdAt),
603+ questions: item.questions.map((question) => ({ ...question, multiSelect: false })),
604+ responseCapability,
605+ });
606+ continue;
607+ }
608+ if (request.kind === "auth_refresh" || request.kind === "dynamic_tool_call") continue;
609+ const item = projection.turnItems.findLast(
610+ (candidate) => candidate.type === "approval_request" && candidate.requestId === request.id,
611+ );
612+ approvals.push({
613+ requestId: request.id,
614+ requestKind: request.kind,
615+ createdAt: iso(request.createdAt),
616+ ...(item?.type === "approval_request" && item.prompt ? { detail: item.prompt } : {}),
617+ responseCapability,
618+ });
619+ }
620+ return { approvals, userInputs };
621+}
622+
623+function presentCheckpoints(
624+ projection: OrchestrationV2ThreadProjection,
625+): ReadonlyArray<ThreadCheckpointSummary> {
626+ return projection.checkpoints.flatMap((checkpoint) => {
627+ if (checkpoint.appRunOrdinal === null || checkpoint.runId === null) return [];
628+ const assistantMessageId =
629+ projection.messages.findLast(
630+ (message) => message.runId === checkpoint.runId && message.role === "assistant",
631+ )?.id ?? null;
632+ return [
633+ {
634+ checkpointId: checkpoint.id,
635+ scopeId: checkpoint.scopeId,
636+ runId: checkpoint.runId,
637+ checkpointTurnCount: checkpoint.appRunOrdinal,
638+ checkpointRef: checkpoint.ref,
639+ status: checkpoint.status,
640+ files: checkpoint.files,
641+ assistantMessageId,
642+ completedAt: iso(checkpoint.capturedAt),
643+ } satisfies ThreadCheckpointSummary,
644+ ];
645+ });
646+}
647+
648+export function presentThread(
40649 environmentId: EnvironmentId,
41− thread: OrchestrationThread,
650+ projection: OrchestrationV2ThreadProjection,
42651 ): EnvironmentThread {
43− return { ...thread, environmentId };
652+ const shell = presentThreadShell(environmentId, {
653+ ...projection.thread,
654+ latestRunId:
655+ projection.runs.reduce<(typeof projection.runs)[number] | null>(
656+ (latest, candidate) =>
657+ latest === null || candidate.ordinal > latest.ordinal ? candidate : latest,
658+ null,
659+ )?.id ?? null,
660+ activeRunId:
661+ projection.runs.findLast(
662+ (run) => run.status === "starting" || run.status === "running" || run.status === "waiting",
663+ )?.id ?? null,
664+ status: presentLatestRun(projection)?.status ?? "idle",
665+ pendingRuntimeRequest:
666+ projection.runtimeRequests.findLast((request) => request.status === "pending") ?? null,
667+ latestVisibleMessage:
668+ projection.messages.length === 0
669+ ? null
670+ : (() => {
671+ const message = projection.messages.at(-1)!;
672+ return {
673+ id: message.id,
674+ role: message.role,
675+ text: message.text,
676+ updatedAt: message.updatedAt,
677+ };
678+ })(),
679+ latestUserMessageAt:
680+ projection.messages.findLast((message) => message.role === "user")?.updatedAt ?? null,
681+ hasActionableProposedPlan: projection.plans.some(
682+ (plan) => plan.kind === "proposed_plan" && plan.status === "active",
683+ ),
684+ itemCount: projection.turnItems.length,
685+ visibleItemCount: projection.visibleTurnItems.length,
686+ });
687+ const requests = presentPendingRequests(projection);
688+ const proposedPlans = presentProposedPlans(projection);
689+ const checkpoints = presentCheckpoints(projection);
690+ const runs = presentRuns(projection);
691+ const capabilities =
692+ projection.providerSessions.findLast(
693+ (session) => session.providerInstanceId === projection.thread.providerInstanceId,
694+ )?.capabilities ?? null;
695+ const active = runs.some((run) => threadRunStatusIsActive(run.status));
696+ const mutable = projection.thread.archivedAt === null && projection.thread.deletedAt === null;
697+ return {
698+ ...shell,
699+ latestRun: presentLatestRun(projection),
700+ runtime: presentRuntime(shell.source, projection),
701+ hasPendingApprovals: requests.approvals.length > 0,
702+ hasPendingUserInput: requests.userInputs.length > 0,
703+ hasActionableProposedPlan: proposedPlans.some((plan) => plan.status === "active"),
704+ projection,
705+ runs,
706+ capabilities,
707+ availableActions: {
708+ canSendMessage: mutable,
709+ canInterrupt: mutable && active && (capabilities?.turns.supportsInterrupt ?? true),
710+ canRespondToApproval: requests.approvals.some(
711+ (request) => request.responseCapability === "live",
712+ ),
713+ canRespondToUserInput: requests.userInputs.some(
714+ (request) => request.responseCapability === "live",
715+ ),
716+ canRollback: mutable && checkpoints.some((checkpoint) => checkpoint.status === "ready"),
717+ canArchive: projection.thread.archivedAt === null && projection.thread.deletedAt === null,
718+ canUnarchive: projection.thread.archivedAt !== null && projection.thread.deletedAt === null,
719+ canDelete: projection.thread.deletedAt === null,
720+ canUpdateSettings: mutable,
721+ },
722+ messages: presentMessages(projection),
723+ workEntries: presentWorkEntries(projection),
724+ proposedPlans,
725+ todoPlans: presentTodoPlans(projection),
726+ pendingApprovals: requests.approvals,
727+ pendingUserInputs: requests.userInputs,
728+ checkpoints,
729+ };
44730 }
45731
46732 export function selectEnvironmentThreadShell(
47− snapshot: OrchestrationShellSnapshot | null,
733+ snapshot: OrchestrationV2ShellSnapshot | null,
48734 environmentId: EnvironmentId,
49735 threadId: ThreadId,
50736 ): EnvironmentThreadShell | null {
51737 const thread = snapshot?.threads.find((candidate) => candidate.id === threadId) ?? null;
52− return thread ? scopeThreadShell(environmentId, thread) : null;
738+ return thread ? presentThreadShell(environmentId, thread) : null;
739+}
740+
741+export function providerApprovalDecisionIsFinal(_decision: ProviderApprovalDecision): boolean {
742+ return true;
53743 }
packages/client-runtime/src/state/orchestration.ts+4−4
@@ -1,4 +1,4 @@
1−import { ORCHESTRATION_WS_METHODS } from "@t3tools/contracts";
1+import { ORCHESTRATION_V2_WS_METHODS } from "@t3tools/contracts";
22 import { Atom } from "effect/unstable/reactivity";
33
44 import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";
@@ -10,11 +10,11 @@ export function createOrchestrationEnvironmentAtoms<R, E>( · 5 unmodified lines
1010 return {
1111 turnDiff: createEnvironmentRpcQueryAtomFamily(runtime, {
1212 label: "environment-data:orchestration:turn-diff",
13− tag: ORCHESTRATION_WS_METHODS.getTurnDiff,
13+ tag: ORCHESTRATION_V2_WS_METHODS.getTurnDiff,
1414 }),
1515 fullThreadDiff: createEnvironmentRpcQueryAtomFamily(runtime, {
1616 label: "environment-data:orchestration:full-thread-diff",
17− tag: ORCHESTRATION_WS_METHODS.getFullThreadDiff,
17+ tag: ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff,
1818 }),
1919 threadSearch: createEnvironmentRpcQueryAtomFamily(runtime, {
2020 label: "environment-data:orchestration:thread-search",
@@ -24,7 +24,7 @@ export function createOrchestrationEnvironmentAtoms<R, E>( · 3 unmodified lines
2424 }),
2525 archivedShellSnapshot: createEnvironmentRpcQueryAtomFamily(runtime, {
2626 label: "environment-data:orchestration:archived-shell-snapshot",
27− tag: ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot,
27+ tag: ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot,
2828 }),
2929 };
3030 }
packages/client-runtime/src/state/orchestrationV2Projection.test.ts+154−0
@@ -1,10 +1,15 @@
11 import { describe, expect, it } from "vite-plus/test";
22 import {
33 type OrchestrationV2DomainEvent,
4+ type OrchestrationV2Run,
45 type OrchestrationV2ThreadProjection,
6+ type OrchestrationV2TurnItem,
7+ MessageId,
58 ProjectId,
69 ProviderInstanceId,
10+ RunId,
711 ThreadId,
12+ TurnItemId,
813 } from "@t3tools/contracts";
914 import * as DateTime from "effect/DateTime";
1015
@@ -12,6 +17,47 @@ import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection · 1 unmodified line
1217
1318 const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
1419 const threadId = ThreadId.make("thread-reducer");
20+const runId = RunId.make("run-reducer");
21+const run = {
22+ id: runId,
23+ threadId,
24+ ordinal: 1,
25+ providerInstanceId: ProviderInstanceId.make("codex"),
26+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
27+ providerThreadId: null,
28+ userMessageId: MessageId.make("message-reducer"),
29+ rootNodeId: null,
30+ activeAttemptId: null,
31+ status: "completed",
32+ requestedAt: now,
33+ startedAt: now,
34+ completedAt: now,
35+ checkpointId: null,
36+ contextHandoffId: null,
37+} satisfies OrchestrationV2Run;
38+
39+function commandItem(id: string, output = "done"): OrchestrationV2TurnItem {
40+ return {
41+ id: TurnItemId.make(id),
42+ threadId,
43+ runId,
44+ nodeId: null,
45+ providerThreadId: null,
46+ providerTurnId: null,
47+ nativeItemRef: null,
48+ parentItemId: null,
49+ ordinal: 1,
50+ status: "completed",
51+ title: null,
52+ startedAt: now,
53+ completedAt: now,
54+ updatedAt: now,
55+ type: "command_execution",
56+ input: "pwd",
57+ output,
58+ exitCode: 0,
59+ };
60+}
1561 const emptyProjection = {
1662 thread: {
1763 id: threadId,
@@ -79,4 +125,112 @@ describe("applyOrchestrationV2ProjectionEvent", () => { · 61 unmodified lines
79125
80126 expect(applyOrchestrationV2ProjectionEvent(emptyProjection, event)).toBe(emptyProjection);
81127 });
128+
129+ it("preserves visible row identity when run updates do not change membership", () => {
130+ const item = commandItem("item-stable");
131+ const visibleTurnItems = [
132+ {
133+ position: 0,
134+ visibility: "local" as const,
135+ sourceThreadId: threadId,
136+ sourceItemId: item.id,
137+ item,
138+ },
139+ ];
140+ const projection = {
141+ ...emptyProjection,
142+ runs: [run],
143+ turnItems: [item],
144+ visibleTurnItems,
145+ };
146+ const event = {
147+ id: "event-run-update",
148+ type: "run.updated",
149+ threadId,
150+ runId,
151+ occurredAt: now,
152+ payload: { ...run, status: "completed" },
153+ } as OrchestrationV2DomainEvent;
154+
155+ const next = applyOrchestrationV2ProjectionEvent(projection, event);
156+ expect(next?.visibleTurnItems).toBe(visibleTurnItems);
157+ expect(next?.visibleTurnItems[0]).toBe(visibleTurnItems[0]);
158+ });
159+
160+ it("replaces only the updated visible item when membership is unchanged", () => {
161+ const first = commandItem("item-first", "first");
162+ const second = commandItem("item-second", "second");
163+ const firstRow = {
164+ position: 0,
165+ visibility: "local" as const,
166+ sourceThreadId: threadId,
167+ sourceItemId: first.id,
168+ item: first,
169+ };
170+ const secondRow = {
171+ position: 1,
172+ visibility: "local" as const,
173+ sourceThreadId: threadId,
174+ sourceItemId: second.id,
175+ item: second,
176+ };
177+ const updated = commandItem("item-first", "streamed output");
178+ const projection = {
179+ ...emptyProjection,
180+ runs: [run],
181+ turnItems: [first, second],
182+ visibleTurnItems: [firstRow, secondRow],
183+ };
184+ const event = {
185+ id: "event-item-update",
186+ type: "turn-item.updated",
187+ threadId,
188+ runId,
189+ occurredAt: now,
190+ payload: updated,
191+ } as OrchestrationV2DomainEvent;
192+
193+ const next = applyOrchestrationV2ProjectionEvent(projection, event);
194+ expect(next?.visibleTurnItems).not.toBe(projection.visibleTurnItems);
195+ expect(next?.visibleTurnItems[0]).not.toBe(firstRow);
196+ expect(next?.visibleTurnItems[0]?.item).toBe(updated);
197+ expect(next?.visibleTurnItems[1]).toBe(secondRow);
198+ });
199+
200+ it("removes only hidden local items while preserving inherited rows", () => {
201+ const inherited = commandItem("item-inherited");
202+ const local = commandItem("item-local");
203+ const inheritedRow = {
204+ position: 0,
205+ visibility: "inherited" as const,
206+ sourceThreadId: ThreadId.make("thread-source"),
207+ sourceItemId: inherited.id,
208+ item: inherited,
209+ };
210+ const localRow = {
211+ position: 1,
212+ visibility: "local" as const,
213+ sourceThreadId: threadId,
214+ sourceItemId: local.id,
215+ item: local,
216+ };
217+ const projection = {
218+ ...emptyProjection,
219+ runs: [run],
220+ turnItems: [local],
221+ visibleTurnItems: [inheritedRow, localRow],
222+ };
223+ const event = {
224+ id: "event-run-rollback",
225+ type: "run.updated",
226+ threadId,
227+ runId,
228+ occurredAt: now,
229+ payload: { ...run, status: "rolled_back" },
230+ } as OrchestrationV2DomainEvent;
231+
232+ const next = applyOrchestrationV2ProjectionEvent(projection, event);
233+ expect(next?.visibleTurnItems).toEqual([inheritedRow]);
234+ expect(next?.visibleTurnItems[0]).toBe(inheritedRow);
235+ });
82236 });
packages/client-runtime/src/state/orchestrationV2Projection.ts+34−9
@@ -20,9 +20,15 @@ function removeVisibleItem( · 19 unmodified lines
2020 rows: OrchestrationV2ThreadProjection["visibleTurnItems"],
2121 sourceItemId: OrchestrationV2TurnItem["id"],
2222 ): OrchestrationV2ThreadProjection["visibleTurnItems"] {
23− return rows
24− .filter((row) => row.sourceItemId !== sourceItemId)
25− .map((row, position) => ({ ...row, position }));
23+ const index = rows.findIndex((row) => row.sourceItemId === sourceItemId);
24+ if (index === -1) return rows;
25+ return renumberVisibleItems([...rows.slice(0, index), ...rows.slice(index + 1)]);
26+}
27+
28+function renumberVisibleItems(
29+ rows: OrchestrationV2ThreadProjection["visibleTurnItems"],
30+): OrchestrationV2ThreadProjection["visibleTurnItems"] {
31+ return rows.map((row, position) => (row.position === position ? row : { ...row, position }));
2632 }
2733
2834 function shouldShowLocalTurnItem(
@@ -39,9 +45,18 @@ function shouldShowLocalTurnItem( · 10 unmodified lines
3945 function activeVisibleTurnItems(
4046 projection: OrchestrationV2ThreadProjection,
4147 ): OrchestrationV2ThreadProjection["visibleTurnItems"] {
42− return projection.visibleTurnItems
43− .filter((row) => row.visibility !== "local" || shouldShowLocalTurnItem(projection, row.item))
44− .map((row, position) => ({ ...row, position }));
48+ const rows = projection.visibleTurnItems;
49+ let next: Array<(typeof rows)[number]> | null = null;
50+ for (let index = 0; index < rows.length; index += 1) {
51+ const row = rows[index]!;
52+ const keep = row.visibility !== "local" || shouldShowLocalTurnItem(projection, row.item);
53+ if (!keep) {
54+ next ??= rows.slice(0, index);
55+ continue;
56+ }
57+ next?.push(row);
58+ }
59+ return next === null ? rows : renumberVisibleItems(next);
4560 }
4661
4762 function upsertVisibleTurnItem(
@@ -57,9 +72,19 @@ function upsertVisibleTurnItem( · 9 unmodified lines
5772 sourceItemId: item.id,
5873 item,
5974 };
60− return index === -1
61− ? [...rows, next]
62− : rows.map((row, rowIndex) => (rowIndex === index ? next : row));
75+ if (index === -1) return [...rows, next];
76+ const previous = rows[index]!;
77+ if (
78+ previous.visibility === next.visibility &&
79+ previous.sourceThreadId === next.sourceThreadId &&
80+ previous.sourceItemId === next.sourceItemId &&
81+ previous.item === item
82+ ) {
83+ return rows;
84+ }
85+ const updated = [...rows];
86+ updated[index] = next;
87+ return updated;
6388 }
6489
6590 /** Applies one committed event to a matching thread projection. */
packages/client-runtime/src/state/orchestrationV2TestFixtures.ts+104−0
@@ -0,0 +1,104 @@
1+import {
2+ ProjectId,
3+ ProviderInstanceId,
4+ ThreadId,
5+ type OrchestrationProjectShell,
6+ type OrchestrationV2ShellSnapshot,
7+ type OrchestrationV2ThreadProjection,
8+ type OrchestrationV2ThreadShell,
9+} from "@t3tools/contracts";
10+import * as DateTime from "effect/DateTime";
11+
12+export const v2Now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
13+export const v2ProjectId = ProjectId.make("project-v2");
14+export const v2ThreadId = ThreadId.make("thread-v2");
15+export const v2ProviderInstanceId = ProviderInstanceId.make("codex");
16+
17+export const v2Project: OrchestrationProjectShell = {
18+ id: v2ProjectId,
19+ title: "Project",
20+ workspaceRoot: "/workspace/project",
21+ repositoryIdentity: null,
22+ defaultModelSelection: null,
23+ scripts: [],
24+ createdAt: "2026-06-20T00:00:00.000Z",
25+ updatedAt: "2026-06-20T00:00:00.000Z",
26+};
27+
28+export const v2ThreadShell: OrchestrationV2ThreadShell = {
29+ id: v2ThreadId,
30+ projectId: v2ProjectId,
31+ title: "Thread",
32+ providerInstanceId: v2ProviderInstanceId,
33+ modelSelection: { instanceId: v2ProviderInstanceId, model: "gpt-5.4" },
34+ runtimeMode: "full-access",
35+ interactionMode: "default",
36+ branch: null,
37+ worktreePath: null,
38+ activeProviderThreadId: null,
39+ lineage: { rootThreadId: v2ThreadId, parentThreadId: null, relationshipToParent: null },
40+ forkedFrom: null,
41+ createdBy: "user",
42+ creationSource: "web",
43+ latestRunId: null,
44+ activeRunId: null,
45+ status: "idle",
46+ pendingRuntimeRequest: null,
47+ latestVisibleMessage: null,
48+ latestUserMessageAt: null,
49+ hasActionableProposedPlan: false,
50+ itemCount: 0,
51+ visibleItemCount: 0,
52+ createdAt: v2Now,
53+ updatedAt: v2Now,
54+ archivedAt: null,
55+ deletedAt: null,
56+};
57+
58+export const v2Projection: OrchestrationV2ThreadProjection = {
59+ thread: {
60+ id: v2ThreadShell.id,
61+ projectId: v2ThreadShell.projectId,
62+ title: v2ThreadShell.title,
63+ providerInstanceId: v2ThreadShell.providerInstanceId,
64+ modelSelection: v2ThreadShell.modelSelection,
65+ runtimeMode: v2ThreadShell.runtimeMode,
66+ interactionMode: v2ThreadShell.interactionMode,
67+ branch: v2ThreadShell.branch,
68+ worktreePath: v2ThreadShell.worktreePath,
69+ activeProviderThreadId: v2ThreadShell.activeProviderThreadId,
70+ lineage: v2ThreadShell.lineage,
71+ forkedFrom: v2ThreadShell.forkedFrom,
72+ createdBy: v2ThreadShell.createdBy,
73+ creationSource: v2ThreadShell.creationSource,
74+ createdAt: v2Now,
75+ updatedAt: v2Now,
76+ archivedAt: null,
77+ deletedAt: null,
78+ },
79+ runs: [],
80+ attempts: [],
81+ nodes: [],
82+ subagents: [],
83+ providerSessions: [],
84+ providerThreads: [],
85+ providerTurns: [],
86+ runtimeRequests: [],
87+ messages: [],
88+ plans: [],
89+ turnItems: [],
90+ checkpointScopes: [],
91+ checkpoints: [],
92+ contextHandoffs: [],
93+ contextTransfers: [],
94+ visibleTurnItems: [],
95+ updatedAt: v2Now,
96+};
97+
98+export const v2ShellSnapshot: OrchestrationV2ShellSnapshot = {
99+ schemaVersion: 1,
100+ snapshotSequence: 0,
101+ projects: [v2Project],
102+ threads: [v2ThreadShell],
103+ archivedThreads: [],
104+};
packages/client-runtime/src/state/projectEntities.ts+2−2
@@ -1,7 +1,7 @@
11 import type {
22 EnvironmentId,
33 OrchestrationProjectShell,
4− OrchestrationShellSnapshot,
4+ OrchestrationV2ShellSnapshot,
55 ProjectId,
66 ScopedProjectRef,
77 } from "@t3tools/contracts";
@@ -19,7 +19,7 @@ export function createEnvironmentProjectAtoms(input: { · 11 unmodified lines
1919 readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>;
2020 readonly snapshotAtom: (
2121 environmentId: EnvironmentId,
22− ) => Atom.Atom<OrchestrationShellSnapshot | null>;
22+ ) => Atom.Atom<OrchestrationV2ShellSnapshot | null>;
2323 }) {
2424 const environmentProjectsAtom = Atom.family((environmentId: EnvironmentId) =>
2525 Atom.make(
packages/client-runtime/src/state/shell-sync.test.ts+9−19
@@ -1,8 +1,8 @@
11 import {
22 EnvironmentId,
3− ORCHESTRATION_WS_METHODS,
4− type OrchestrationShellSnapshot,
5− type OrchestrationShellStreamItem,
3+ ORCHESTRATION_V2_WS_METHODS,
4+ type OrchestrationV2ShellSnapshot,
5+ type OrchestrationV2ShellStreamItem,
66 } from "@t3tools/contracts";
77 import { describe, expect, it } from "@effect/vitest";
88 import * as Effect from "effect/Effect";
@@ -22,7 +22,8 @@ import * as ConnectionWakeups from "../connection/wakeups.ts"; · 13 unmodified lines
2222 import * as Persistence from "../platform/persistence.ts";
2323 import * as RpcSession from "../rpc/session.ts";
2424 import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
25−import { makeEnvironmentShellState, ShellSnapshotLoader } from "./shell.ts";
25+import { makeEnvironmentShellState } from "./shell.ts";
26+import { v2ShellSnapshot } from "./orchestrationV2TestFixtures.ts";
2627
2728 const TARGET = new PrimaryConnectionTarget({
2829 environmentId: EnvironmentId.make("environment-1"),
@@ -31,20 +32,9 @@ const TARGET = new PrimaryConnectionTarget({ · 2 unmodified lines
3132 wsBaseUrl: "wss://environment.example.test",
3233 });
3334
34−const PREPARED: PreparedConnection = {
35− environmentId: TARGET.environmentId,
36− label: TARGET.label,
37− httpBaseUrl: TARGET.httpBaseUrl,
38− socketUrl: TARGET.wsBaseUrl,
39− httpAuthorization: null,
40− target: TARGET,
41−};
42−
43−const LIVE_SHELL_SNAPSHOT: OrchestrationShellSnapshot = {
35+const LIVE_SHELL_SNAPSHOT: OrchestrationV2ShellSnapshot = {
36+ ...v2ShellSnapshot,
4437 snapshotSequence: 1,
45− projects: [],
46− threads: [],
47− updatedAt: "2026-06-06T00:00:00.000Z",
4838 };
4939
5040 function session(client: WsRpcProtocolClient): RpcSession.RpcSession {
@@ -60,9 +50,9 @@ function session(client: WsRpcProtocolClient): RpcSession.RpcSession { · 9 unmodified lines
6050 describe("environment shell synchronization", () => {
6151 it.effect("publishes live state before persistence and preserves it when ready", () =>
6252 Effect.gen(function* () {
63− const events = yield* Queue.unbounded<OrchestrationShellStreamItem>();
53+ const events = yield* Queue.unbounded<OrchestrationV2ShellStreamItem>();
6454 const client = {
65− [ORCHESTRATION_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events),
55+ [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events),
6656 } as unknown as WsRpcProtocolClient;
6757 const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE);
6858 const activeSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>(
packages/client-runtime/src/state/shell.test.ts+10−2
@@ -2,11 +2,13 @@ import type { ServerConfig } from "@t3tools/contracts"; · 1 unmodified line
22 import { EnvironmentId } from "@t3tools/contracts";
33 import { describe, expect, it } from "@effect/vitest";
44 import * as Option from "effect/Option";
5+import * as DateTime from "effect/DateTime";
56 import { Atom, AtomRegistry } from "effect/unstable/reactivity";
67
78 import { PrimaryConnectionTarget } from "../connection/model.ts";
89 import type { EnvironmentShellState } from "./shell.ts";
910 import { createEnvironmentServerConfigsAtom, createEnvironmentShellSummaryAtom } from "./shell.ts";
11+import { v2ThreadShell } from "./orchestrationV2TestFixtures.ts";
1012
1113 const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
1214 const OTHER_ENVIRONMENT_ID = EnvironmentId.make("environment-2");
@@ -34,10 +36,16 @@ function shellState(input: { · 21 unmodified lines
3436 input.updatedAt === undefined
3537 ? Option.none()
3638 : Option.some({
39+ schemaVersion: 1,
3740 snapshotSequence: input.snapshotSequence ?? 1,
38− updatedAt: input.updatedAt,
3941 projects: [],
40− threads: [],
42+ threads: [
43+ {
44+ ...v2ThreadShell,
45+ updatedAt: DateTime.makeUnsafe(input.updatedAt),
46+ },
47+ ],
48+ archivedThreads: [],
4149 }),
4250 status: input.status,
4351 error: input.error === undefined ? Option.none() : Option.some(input.error),
packages/client-runtime/src/state/shell.ts+31−62
@@ -1,11 +1,12 @@
11 import {
2− ORCHESTRATION_WS_METHODS,
2+ ORCHESTRATION_V2_WS_METHODS,
33 type EnvironmentId,
4− type OrchestrationShellSnapshot,
5− type OrchestrationShellStreamItem,
4+ type OrchestrationV2ShellSnapshot,
5+ type OrchestrationV2ShellStreamItem,
66 type ServerConfig,
77 } from "@t3tools/contracts";
88 import * as Cause from "effect/Cause";
9+import * as DateTime from "effect/DateTime";
910 import * as Effect from "effect/Effect";
1011 import * as Option from "effect/Option";
1112 import * as Queue from "effect/Queue";
@@ -29,7 +30,7 @@ import { followStreamInEnvironment } from "./runtime.ts"; · 17 unmodified lines
2930 export type EnvironmentShellStatus = "empty" | "cached" | "synchronizing" | "live";
3031
3132 export interface EnvironmentShellState {
32− readonly snapshot: Option.Option<OrchestrationShellSnapshot>;
33+ readonly snapshot: Option.Option<OrchestrationV2ShellSnapshot>;
3334 readonly status: EnvironmentShellStatus;
3435 readonly error: Option.Option<string>;
3536 }
@@ -41,7 +42,7 @@ const EMPTY_SHELL_STATE: EnvironmentShellState = { · 5 unmodified lines
4142 };
4243
4344 function shellStatusForSnapshot(
44− snapshot: Option.Option<OrchestrationShellSnapshot>,
45+ snapshot: Option.Option<OrchestrationV2ShellSnapshot>,
4546 ): EnvironmentShellStatus {
4647 return Option.isSome(snapshot) ? "cached" : "empty";
4748 }
@@ -61,7 +62,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 13 unmodified lines
6162 environmentId,
6263 ...safeErrorLogAttributes(error),
6364 }),
64− Effect.as(Option.none<OrchestrationShellSnapshot>()),
65+ Effect.as(Option.none<OrchestrationV2ShellSnapshot>()),
6566 ),
6667 ),
6768 );
@@ -70,11 +71,10 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 2 unmodified lines
7071 status: shellStatusForSnapshot(cachedSnapshot),
7172 error: Option.none(),
7273 });
73− const awaitingCompletion = yield* Ref.make(false);
74− const persistence = yield* Queue.sliding<OrchestrationShellSnapshot>(1);
74+ const persistence = yield* Queue.sliding<OrchestrationV2ShellSnapshot>(1);
7575
7676 const persist = Effect.fn("EnvironmentShellState.persist")(function* (
77− snapshot: OrchestrationShellSnapshot,
77+ snapshot: OrchestrationV2ShellSnapshot,
7878 ) {
7979 yield* cache.saveShell(environmentId, snapshot).pipe(
8080 Effect.catch((error) =>
@@ -133,7 +133,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 52 unmodified lines
133133 );
134134
135135 const applyItem = Effect.fn("EnvironmentShellState.applyItem")(function* (
136− item: OrchestrationShellStreamItem,
136+ item: OrchestrationV2ShellStreamItem,
137137 ) {
138138 if (item.kind === "synchronized") {
139139 yield* Ref.set(awaitingCompletion, false);
@@ -169,56 +169,13 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 29 unmodified lines
169169 yield* Queue.offer(persistence, nextSnapshot);
170170 });
171171
172− const foregroundResubscriptions = Option.match(wakeups, {
173− onNone: () => Stream.never,
174− onSome: (service) =>
175− service.changes.pipe(Stream.filter(ConnectionWakeups.shouldResubscribeAfterWakeup)),
176− });
177−
178− yield* setSynchronizing;
179− yield* Effect.forkScoped(
180− subscribeDynamic(
181− ORCHESTRATION_WS_METHODS.subscribeShell,
182− Effect.fn("EnvironmentShellState.makeSubscribeInput")(function* (session) {
183− const supportsCompletionMarker = yield* session.initialConfig.pipe(
184− Effect.map((config) => config.shellResumeCompletionMarker === true),
185− Effect.orElseSucceed(() => false),
186− );
187− yield* Ref.set(awaitingCompletion, supportsCompletionMarker);
188− yield* setSynchronizing;
189−
190− const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe(
191− Effect.flatMap(
192− Option.match({
193− onSome: Effect.succeed,
194− onNone: () =>
195− SubscriptionRef.changes(supervisor.prepared).pipe(
196− Stream.filter(Option.isSome),
197− Stream.map((value) => value.value),
198− Stream.runHead,
199− Effect.map(Option.getOrThrow),
200− ),
201− }),
202− ),
203− );
204− const httpSnapshot = yield* snapshotLoader.load(prepared);
205− if (Option.isSome(httpSnapshot)) {
206− yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value });
207− return {
208− afterSequence: httpSnapshot.value.snapshotSequence,
209− ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}),
210− };
211− }
212−
213− return supportsCompletionMarker ? { requestCompletionMarker: true as const } : {};
214− }),
215− {
216− onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)),
217− retryExpectedFailureAfter: "250 millis",
218− resubscribe: foregroundResubscriptions,
219− },
220− ).pipe(Stream.runForEach(applyItem)),
221− );
172+ yield* subscribe(
173+ ORCHESTRATION_V2_WS_METHODS.subscribeShell,
174+ {},
175+ {
176+ onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)),
177+ },
178+ ).pipe(Stream.runForEach(applyItem), Effect.forkScoped);
222179 yield* SubscriptionRef.changes(supervisor.state).pipe(
223180 Stream.runForEach((connectionState) => {
224181 switch (connectionProjectionPhase(connectionState)) {
@@ -314,8 +271,20 @@ export function createEnvironmentShellSummaryAtom(input: { · 89 unmodified lines
314271 continue;
315272 }
316273 hasSnapshot = true;
317− const updatedAt = state.snapshot.value.updatedAt;
318− if (latestSnapshotUpdatedAt === null || updatedAt > latestSnapshotUpdatedAt) {
274+ const snapshot = state.snapshot.value;
275+ const updatedAt = snapshot.threads.concat(snapshot.archivedThreads).reduce<string | null>(
276+ (latest, thread) => {
277+ const value = DateTime.formatIso(thread.updatedAt);
278+ return latest === null || value > latest ? value : latest;
279+ },
280+ snapshot.projects.reduce<string | null>((latest, project) => {
281+ return latest === null || project.updatedAt > latest ? project.updatedAt : latest;
282+ }, null),
283+ );
284+ if (
285+ updatedAt !== null &&
286+ (latestSnapshotUpdatedAt === null || updatedAt > latestSnapshotUpdatedAt)
287+ ) {
319288 latestSnapshotUpdatedAt = updatedAt;
320289 }
321290 }
packages/client-runtime/src/state/shellReducer.test.ts+52−153
@@ -1,185 +1,84 @@
1+import { ProjectId, ThreadId } from "@t3tools/contracts";
12 import { describe, expect, it } from "vite-plus/test";
23
3−import { ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
4−import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts";
5−
4+import { v2Project, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts";
65 import { applyShellStreamEvent } from "./shellReducer.ts";
76
8−const baseSnapshot: OrchestrationShellSnapshot = {
9− snapshotSequence: 0,
10− projects: [],
11− threads: [],
12− updatedAt: "2026-04-01T00:00:00.000Z",
13−};
14−
15−const stubProject = {
16− id: ProjectId.make("project-1"),
17− title: "Test Project",
18− workspaceRoot: "/workspace/test",
19− repositoryIdentity: null,
20− defaultModelSelection: null,
21− scripts: [],
22− createdAt: "2026-04-01T00:00:00.000Z",
23− updatedAt: "2026-04-01T00:00:00.000Z",
24−} as const;
25−
26−const stubThread = {
27− id: ThreadId.make("thread-1"),
28− projectId: ProjectId.make("project-1"),
29− title: "Test Thread",
30− modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
31− runtimeMode: "full-access" as const,
32− interactionMode: "default" as const,
33− branch: null,
34− worktreePath: null,
35− latestTurn: null,
36− createdAt: "2026-04-01T00:00:00.000Z",
37− updatedAt: "2026-04-01T00:00:00.000Z",
38− archivedAt: null,
39− settledOverride: null,
40− settledAt: null,
41− latestUserMessageAt: null,
42− hasPendingApprovals: false,
43− hasPendingUserInput: false,
44− hasActionableProposedPlan: false,
45− session: null,
46−} as const;
47−
487 describe("applyShellStreamEvent", () => {
49− it("ignores stale project upserts without mutating the snapshot", () => {
50− const snapshotWithProject: OrchestrationShellSnapshot = {
51− ...baseSnapshot,
8+ it("ignores stale project updates without mutating the snapshot", () => {
9+ const snapshotWithProject = {
10+ ...v2ShellSnapshot,
5211 snapshotSequence: 4,
53− projects: [stubProject],
12+ projects: [v2Project],
5413 };
5514
5615 for (const sequence of [3, 4]) {
5716 const next = applyShellStreamEvent(snapshotWithProject, {
58− kind: "project-upserted",
17+ kind: "project.updated",
5918 sequence,
60− project: { ...stubProject, title: "Stale Title" },
19+ project: { ...v2Project, title: "Stale Title" },
6120 });
6221
6322 expect(next).toBe(snapshotWithProject);
6423 expect(next.snapshotSequence).toBe(4);
65− expect(next.projects[0]?.title).toBe("Test Project");
24+ expect(next.projects[0]?.title).toBe(v2Project.title);
6625 }
6726 });
6827
69− describe("project-upserted", () => {
70− it("adds a new project", () => {
71− const event: OrchestrationShellStreamEvent = {
72− kind: "project-upserted",
73− sequence: 1,
74− project: stubProject,
75− };
76−
77− const next = applyShellStreamEvent(baseSnapshot, event);
78−
79− expect(next.projects).toHaveLength(1);
80− expect(next.projects[0]?.id).toBe("project-1");
81− expect(next.snapshotSequence).toBe(1);
28+ it("applies project updates and removals", () => {
29+ const updated = applyShellStreamEvent(v2ShellSnapshot, {
30+ kind: "project.updated",
31+ sequence: 1,
32+ project: { ...v2Project, title: "Updated" },
8233 });
34+ expect(updated.projects[0]?.title).toBe("Updated");
35+ expect(updated.snapshotSequence).toBe(1);
8336
84− it("updates an existing project", () => {
85− const snapshotWithProject: OrchestrationShellSnapshot = {
86− ...baseSnapshot,
87− projects: [stubProject],
88− };
89−
90− const updatedProject = { ...stubProject, title: "Updated Title" };
91− const event: OrchestrationShellStreamEvent = {
92− kind: "project-upserted",
93− sequence: 2,
94− project: updatedProject,
95− };
96−
97− const next = applyShellStreamEvent(snapshotWithProject, event);
98−
99− expect(next.projects).toHaveLength(1);
100− expect(next.projects[0]?.title).toBe("Updated Title");
101− expect(next.snapshotSequence).toBe(2);
102− });
103− });
104−
105− describe("project-removed", () => {
106− it("removes a project by id", () => {
107− const snapshotWithProject: OrchestrationShellSnapshot = {
108− ...baseSnapshot,
109− projects: [stubProject],
110− };
111−
112− const event: OrchestrationShellStreamEvent = {
113− kind: "project-removed",
114− sequence: 3,
115− projectId: ProjectId.make("project-1"),
116− };
117−
118− const next = applyShellStreamEvent(snapshotWithProject, event);
119−
120− expect(next.projects).toHaveLength(0);
121− expect(next.snapshotSequence).toBe(3);
37+ const removed = applyShellStreamEvent(updated, {
38+ kind: "project.removed",
39+ sequence: 2,
40+ projectId: ProjectId.make(v2Project.id),
12241 });
42+ expect(removed.projects).toEqual([]);
12343 });
12444
125− describe("thread-upserted", () => {
126− it("adds a new thread", () => {
127− const event: OrchestrationShellStreamEvent = {
128− kind: "thread-upserted",
129− sequence: 4,
130− thread: stubThread,
131− };
132−
133− const next = applyShellStreamEvent(baseSnapshot, event);
134−
135− expect(next.threads).toHaveLength(1);
136− expect(next.threads[0]?.id).toBe("thread-1");
137− expect(next.snapshotSequence).toBe(4);
45+ it("moves a thread between active and archive without duplicating it", () => {
46+ const archived = applyShellStreamEvent(v2ShellSnapshot, {
47+ kind: "thread.updated",
48+ sequence: 3,
49+ location: "archive",
50+ thread: { ...v2ThreadShell, archivedAt: v2ThreadShell.updatedAt },
13851 });
139−
140− it("updates an existing thread", () => {
141− const snapshotWithThread: OrchestrationShellSnapshot = {
142− ...baseSnapshot,
143− threads: [stubThread],
144− };
145−
146− const updatedThread = { ...stubThread, title: "Updated Thread" };
147− const event: OrchestrationShellStreamEvent = {
148− kind: "thread-upserted",
149− sequence: 5,
150− thread: updatedThread,
151− };
152−
153− const next = applyShellStreamEvent(snapshotWithThread, event);
154−
155− expect(next.threads).toHaveLength(1);
156− expect(next.threads[0]?.title).toBe("Updated Thread");
52+ expect(archived.threads).toEqual([]);
53+ expect(archived.archivedThreads).toHaveLength(1);
54+
55+ const active = applyShellStreamEvent(archived, {
56+ kind: "thread.updated",
57+ sequence: 4,
58+ location: "active",
59+ thread: v2ThreadShell,
15760 });
61+ expect(active.threads).toHaveLength(1);
62+ expect(active.archivedThreads).toEqual([]);
15863 });
15964
160− describe("thread-removed", () => {
161− it("removes a thread by id", () => {
162− const snapshotWithThread: OrchestrationShellSnapshot = {
163− ...baseSnapshot,
164− threads: [stubThread],
165− };
166−
167− const event: OrchestrationShellStreamEvent = {
168− kind: "thread-removed",
169− sequence: 6,
170− threadId: ThreadId.make("thread-1"),
171− };
172−
173− const next = applyShellStreamEvent(snapshotWithThread, event);
174−
175− expect(next.threads).toHaveLength(0);
176− expect(next.snapshotSequence).toBe(6);
65+ it("removes a thread from either collection", () => {
66+ const next = applyShellStreamEvent(v2ShellSnapshot, {
67+ kind: "thread.removed",
68+ sequence: 5,
69+ location: "active",
70+ threadId: ThreadId.make(v2ThreadShell.id),
17771 });
72+ expect(next.threads).toEqual([]);
73+ expect(next.snapshotSequence).toBe(5);
17874 });
17975
180− it("returns original snapshot for unrecognized event kinds", () => {
181− const unknownEvent = { kind: "unknown-future-event", sequence: 99 } as any;
182− const next = applyShellStreamEvent(baseSnapshot, unknownEvent);
183− expect(next).toBe(baseSnapshot);
76+ it("leaves unknown future events unchanged", () => {
77+ const next = applyShellStreamEvent(
78+ v2ShellSnapshot,
79+ { kind: "future.event", sequence: 99 } as never,
80+ );
81+
82+ expect(next).toBe(v2ShellSnapshot);
18483 });
18584 });
packages/client-runtime/src/state/shellReducer.ts+43−28
@@ -1,43 +1,58 @@
1−import * as Arr from "effect/Array";
2−import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts";
1+import type {
2+ OrchestrationV2ShellSnapshot,
3+ OrchestrationV2ShellStreamItem,
4+} from "@t3tools/contracts";
35
4−/**
5− * Reduce a single shell stream event into an existing snapshot, returning a new
6− * snapshot with the event's changes applied. This is a pure reducer that both
7− * web and mobile can use to keep their local shell snapshot in sync.
8− *
9− * Returns the original snapshot reference unchanged if the event is not
10− * recognized (forward-compatible).
11− */
6+function upsertById<T extends { readonly id: unknown }>(
7+ items: ReadonlyArray<T>,
8+ item: T,
9+): ReadonlyArray<T> {
10+ const index = items.findIndex((candidate) => candidate.id === item.id);
11+ if (index === -1) return [...items, item];
12+ return items.map((candidate, candidateIndex) => (candidateIndex === index ? item : candidate));
13+}
14+
15+/** Applies one committed V2 shell delta while preserving active/archive exclusivity. */
1216 export function applyShellStreamEvent(
13− snapshot: OrchestrationShellSnapshot,
14− event: OrchestrationShellStreamEvent,
15−): OrchestrationShellSnapshot {
17+ snapshot: OrchestrationV2ShellSnapshot,
18+ event: Exclude<OrchestrationV2ShellStreamItem, { readonly kind: "snapshot" }>,
19+): OrchestrationV2ShellSnapshot {
1620 if (event.sequence <= snapshot.snapshotSequence) return snapshot;
1721
1822 switch (event.kind) {
19− case "project-upserted": {
20− const projects = snapshot.projects.some((p) => p.id === event.project.id)
21− ? Arr.map(snapshot.projects, (p) => (p.id === event.project.id ? event.project : p))
22− : Arr.append(snapshot.projects, event.project);
23− return { ...snapshot, projects, snapshotSequence: event.sequence };
24− }
25− case "project-removed":
23+ case "project.updated":
24+ return {
25+ ...snapshot,
26+ projects: upsertById(snapshot.projects, event.project),
27+ snapshotSequence: event.sequence,
28+ };
29+ case "project.removed":
30+ return {
31+ ...snapshot,
32+ projects: snapshot.projects.filter((project) => project.id !== event.projectId),
33+ snapshotSequence: event.sequence,
34+ };
35+ case "thread.updated": {
36+ const withoutThread = (threads: OrchestrationV2ShellSnapshot["threads"]) =>
37+ threads.filter((thread) => thread.id !== event.thread.id);
2638 return {
2739 ...snapshot,
28− projects: Arr.filter(snapshot.projects, (p) => p.id !== event.projectId),
40+ threads:
41+ event.location === "active"
42+ ? upsertById(withoutThread(snapshot.threads), event.thread)
43+ : withoutThread(snapshot.threads),
44+ archivedThreads:
45+ event.location === "archive"
46+ ? upsertById(withoutThread(snapshot.archivedThreads), event.thread)
47+ : withoutThread(snapshot.archivedThreads),
2948 snapshotSequence: event.sequence,
3049 };
31− case "thread-upserted": {
32− const threads = snapshot.threads.some((t) => t.id === event.thread.id)
33− ? Arr.map(snapshot.threads, (t) => (t.id === event.thread.id ? event.thread : t))
34− : Arr.append(snapshot.threads, event.thread);
35− return { ...snapshot, threads, snapshotSequence: event.sequence };
3650 }
37− case "thread-removed":
51+ case "thread.removed":
3852 return {
3953 ...snapshot,
40− threads: Arr.filter(snapshot.threads, (t) => t.id !== event.threadId),
54+ threads: snapshot.threads.filter((thread) => thread.id !== event.threadId),
55+ archivedThreads: snapshot.archivedThreads.filter((thread) => thread.id !== event.threadId),
4156 snapshotSequence: event.sequence,
4257 };
4358 default:
packages/client-runtime/src/state/snapshots.ts+2−2
@@ -1,4 +1,4 @@
1−import type { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts";
1+import type { EnvironmentId, OrchestrationV2ShellSnapshot } from "@t3tools/contracts";
22 import * as Option from "effect/Option";
33 import { AsyncResult, Atom } from "effect/unstable/reactivity";
44
@@ -10,7 +10,7 @@ export function createEnvironmentSnapshotAtom<E>( · 5 unmodified lines
1010 ) => Atom.Atom<AsyncResult.AsyncResult<EnvironmentShellState, E>>,
1111 ) {
1212 return Atom.family((environmentId: EnvironmentId) =>
13− Atom.make((get): OrchestrationShellSnapshot | null =>
13+ Atom.make((get): OrchestrationV2ShellSnapshot | null =>
1414 Option.match(AsyncResult.value(get(shellStateAtom(environmentId))), {
1515 onNone: () => null,
1616 onSome: (state) => Option.getOrNull(state.snapshot),
packages/client-runtime/src/state/threadCommands.ts+1−0
@@ -54,6 +54,7 @@ export type { · 53 unmodified lines
5454 SnoozeThreadInput,
5555 StartThreadTurnInput,
5656 StopThreadSessionInput,
57+ ThreadCommandInput,
5758 UnarchiveThreadInput,
5859 UnsettleThreadInput,
5960 UnsnoozeThreadInput,
packages/client-runtime/src/state/threadDetail.test.ts+45−0
@@ -0,0 +1,45 @@
1+import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts";
2+import * as Option from "effect/Option";
3+import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
4+import { describe, expect, it } from "vite-plus/test";
5+
6+import { v2Projection } from "./orchestrationV2TestFixtures.ts";
7+import { createEnvironmentThreadDetailAtoms } from "./threadDetail.ts";
8+import type { EnvironmentThreadState } from "./threads.ts";
9+
10+const ref: ScopedThreadRef = {
11+ environmentId: EnvironmentId.make("environment-detail"),
12+ threadId: ThreadId.make(v2Projection.thread.id),
13+};
14+
15+describe("createEnvironmentThreadDetailAtoms", () => {
16+ it("adds environment scope while preserving the pristine projection", () => {
17+ const initial: AsyncResult.AsyncResult<EnvironmentThreadState, never> = AsyncResult.success({
18+ data: Option.some(v2Projection),
19+ status: "cached",
20+ error: Option.none(),
21+ });
22+ const sourceAtom = Atom.make(initial);
23+ const details = createEnvironmentThreadDetailAtoms(() => sourceAtom);
24+ const registry = AtomRegistry.make();
25+
26+ const thread = registry.get(details.threadAtom(ref));
27+ expect(thread).toEqual({ environmentId: ref.environmentId, projection: v2Projection });
28+ expect(thread?.projection).toBe(v2Projection);
29+ expect(registry.get(details.visibleTurnItemsAtom(ref))).toBe(v2Projection.visibleTurnItems);
30+ expect(registry.get(details.statusAtom(ref))).toBe("cached");
31+
32+ registry.set(
33+ sourceAtom,
34+ AsyncResult.success({
35+ data: Option.some(v2Projection),
36+ status: "synchronizing",
37+ error: Option.none(),
38+ }),
39+ );
40+
41+ expect(registry.get(details.threadAtom(ref))).toBe(thread);
42+ expect(registry.get(details.statusAtom(ref))).toBe("synchronizing");
43+ registry.dispose();
44+ });
45+});
packages/client-runtime/src/state/threadDetail.ts+104−117
@@ -1,67 +1,48 @@
1−import type {
2− OrchestrationCheckpointSummary,
3− OrchestrationLatestTurn,
4− OrchestrationMessage,
5− OrchestrationProposedPlan,
6− OrchestrationSession,
7− OrchestrationThread,
8− OrchestrationThreadActivity,
9− ScopedThreadRef,
10−} from "@t3tools/contracts";
1+import type { OrchestrationV2ThreadProjection, ScopedThreadRef } from "@t3tools/contracts";
112 import * as Option from "effect/Option";
123 import { AsyncResult, Atom } from "effect/unstable/reactivity";
134
14−import type { EnvironmentThread, EnvironmentThreadShell } from "./models.ts";
15−import { scopeThread } from "./models.ts";
16−import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threadState.ts";
5+import type {
6+ EnvironmentThread,
7+ EnvironmentThreadShell,
8+ ThreadCheckpointSummary,
9+ ThreadConversationMessage,
10+ ThreadPendingApproval,
11+ ThreadPendingUserInput,
12+ ThreadProposedPlan,
13+ ThreadRunSummary,
14+ ThreadRuntimeSummary,
15+ ScopedThreadProjection,
16+ ThreadWorkEntry,
17+} from "./models.ts";
18+import { presentThread } from "./models.ts";
19+import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts";
1720 import { parseThreadKey, threadKey } from "./entities.ts";
1821 import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
1922
20−const EMPTY_MESSAGES: ReadonlyArray<OrchestrationMessage> = Object.freeze([]);
21−const EMPTY_ACTIVITIES: ReadonlyArray<OrchestrationThreadActivity> = Object.freeze([]);
22−const EMPTY_PROPOSED_PLANS: ReadonlyArray<OrchestrationProposedPlan> = Object.freeze([]);
23−const EMPTY_CHECKPOINTS: ReadonlyArray<OrchestrationCheckpointSummary> = Object.freeze([]);
23+const EMPTY_MESSAGES: ReadonlyArray<ThreadConversationMessage> = Object.freeze([]);
24+const EMPTY_WORK_ENTRIES: ReadonlyArray<ThreadWorkEntry> = Object.freeze([]);
25+const EMPTY_PROPOSED_PLANS: ReadonlyArray<ThreadProposedPlan> = Object.freeze([]);
26+const EMPTY_CHECKPOINTS: ReadonlyArray<ThreadCheckpointSummary> = Object.freeze([]);
27+const EMPTY_APPROVALS: ReadonlyArray<ThreadPendingApproval> = Object.freeze([]);
28+const EMPTY_USER_INPUTS: ReadonlyArray<ThreadPendingUserInput> = Object.freeze([]);
29+const EMPTY_VISIBLE_TURN_ITEMS: OrchestrationV2ThreadProjection["visibleTurnItems"] = Object.freeze(
30+ [],
31+);
2432
25−/**
26− * Combine detail-only collections with the shell's authoritative thread metadata.
27− *
28− * Shell and detail subscriptions are intentionally independent. A cached detail can
29− * therefore briefly outlive a newer shell snapshot after reconnecting. Workspace
30− * consumers must use the shell branch/worktree/project fields so they do not target
31− * a stale checkout while retaining messages, activities, plans, and checkpoints
32− * from the detail subscription.
33− */
33+/** Shell metadata wins over an independently cached detail projection. */
3434 export function mergeEnvironmentThread(
3535 detail: EnvironmentThread | null,
3636 shell: EnvironmentThreadShell | null,
3737 ): EnvironmentThread | null {
38− if (detail === null || shell === null) {
39− return detail;
40− }
41− if (detail.environmentId !== shell.environmentId || detail.id !== shell.id) {
42− return detail;
43− }
44−
38+ if (detail === null || shell === null) return detail;
39+ if (detail.environmentId !== shell.environmentId || detail.id !== shell.id) return detail;
4540 return {
4641 ...detail,
47− environmentId: shell.environmentId,
48− id: shell.id,
49− projectId: shell.projectId,
50− title: shell.title,
51− modelSelection: shell.modelSelection,
52− runtimeMode: shell.runtimeMode,
53− interactionMode: shell.interactionMode,
54− branch: shell.branch,
55− worktreePath: shell.worktreePath,
56− latestTurn: shell.latestTurn,
57− createdAt: shell.createdAt,
58− updatedAt: shell.updatedAt,
59− archivedAt: shell.archivedAt,
60− settledOverride: shell.settledOverride,
61− settledAt: shell.settledAt,
62− snoozedUntil: shell.snoozedUntil,
63− snoozedAt: shell.snoozedAt,
64− session: shell.session,
42+ ...shell,
43+ latestRun: detail.latestRun,
44+ runtime: detail.runtime,
45+ source: shell.source,
6546 };
6647 }
6748
@@ -86,15 +67,13 @@ export function createEnvironmentThreadDetailAtoms<E>( · 18 unmodified lines
8667
8768 const threadDetailAtomFamily = Atom.family((key: string) => {
8869 const ref = parseThreadKey(key);
89− let previousSource: OrchestrationThread | null = null;
70+ let previousSource: OrchestrationV2ThreadProjection | null = null;
9071 let previousValue: EnvironmentThread | null = null;
9172 return Atom.make((get) => {
9273 const source = Option.getOrNull(get(threadStateValueAtomFamily(key)).data);
93− if (source === previousSource) {
94− return previousValue;
95− }
74+ if (source === previousSource) return previousValue;
9675 previousSource = source;
97− previousValue = source === null ? null : scopeThread(ref.environmentId, source);
76+ previousValue = source === null ? null : presentThread(ref.environmentId, source);
9877 return previousValue;
9978 }).pipe(
10079 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
@@ -102,88 +81,96 @@ export function createEnvironmentThreadDetailAtoms<E>( · 1 unmodified line
10281 );
10382 });
10483
105− const threadStatusAtomFamily = Atom.family((key: string) =>
106− Atom.make((get) => get(threadStateValueAtomFamily(key)).status).pipe(
107− Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
108− Atom.withLabel(`environment-thread-status:${key}`),
109− ),
110− );
111−
112− const threadErrorAtomFamily = Atom.family((key: string) =>
113− Atom.make((get) => Option.getOrNull(get(threadStateValueAtomFamily(key)).error)).pipe(
84+ const scopedThreadProjectionAtomFamily = Atom.family((key: string) => {
85+ const ref = parseThreadKey(key);
86+ let previousProjection: OrchestrationV2ThreadProjection | null = null;
87+ let previousValue: ScopedThreadProjection | null = null;
88+ return Atom.make((get) => {
89+ const projection = Option.getOrNull(get(threadStateValueAtomFamily(key)).data);
90+ if (projection === previousProjection) return previousValue;
91+ previousProjection = projection;
92+ previousValue = projection === null ? null : { environmentId: ref.environmentId, projection };
93+ return previousValue;
94+ }).pipe(
11495 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
115− Atom.withLabel(`environment-thread-error:${key}`),
116− ),
117− );
96+ Atom.withLabel(`scoped-thread-projection:${key}`),
97+ );
98+ });
11899
119− const threadMessagesAtomFamily = Atom.family((key: string) =>
100+ const visibleTurnItemsAtomFamily = Atom.family((key: string) =>
120101 Atom.make(
121− (get): ReadonlyArray<OrchestrationMessage> =>
122− get(threadDetailAtomFamily(key))?.messages ?? EMPTY_MESSAGES,
102+ (get): OrchestrationV2ThreadProjection["visibleTurnItems"] =>
103+ Option.getOrNull(get(threadStateValueAtomFamily(key)).data)?.visibleTurnItems ??
104+ EMPTY_VISIBLE_TURN_ITEMS,
123105 ).pipe(
124106 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
125− Atom.withLabel(`environment-thread-messages:${key}`),
107+ Atom.withLabel(`environment-thread-visible-turn-items:${key}`),
126108 ),
127109 );
128110
129− const threadActivitiesAtomFamily = Atom.family((key: string) =>
130− Atom.make(
131− (get): ReadonlyArray<OrchestrationThreadActivity> =>
132− get(threadDetailAtomFamily(key))?.activities ?? EMPTY_ACTIVITIES,
133− ).pipe(
134− Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
135− Atom.withLabel(`environment-thread-activities:${key}`),
136− ),
137− );
111+ const family = <A>(label: string, select: (thread: EnvironmentThread) => A, empty: A) =>
112+ Atom.family((key: string) =>
113+ Atom.make((get): A => {
114+ const thread = get(threadDetailAtomFamily(key));
115+ return thread === null ? empty : select(thread);
116+ }).pipe(
117+ Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
118+ Atom.withLabel(`environment-thread-${label}:${key}`),
119+ ),
120+ );
138121
139− const threadProposedPlansAtomFamily = Atom.family((key: string) =>
140− Atom.make(
141− (get): ReadonlyArray<OrchestrationProposedPlan> =>
142− get(threadDetailAtomFamily(key))?.proposedPlans ?? EMPTY_PROPOSED_PLANS,
143− ).pipe(
144− Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
145− Atom.withLabel(`environment-thread-proposed-plans:${key}`),
146− ),
122+ const messages = family("messages", (thread) => thread.messages, EMPTY_MESSAGES);
123+ const workEntries = family("work-entries", (thread) => thread.workEntries, EMPTY_WORK_ENTRIES);
124+ const proposedPlans = family(
125+ "proposed-plans",
126+ (thread) => thread.proposedPlans,
127+ EMPTY_PROPOSED_PLANS,
147128 );
148−
149− const threadCheckpointsAtomFamily = Atom.family((key: string) =>
150− Atom.make(
151− (get): ReadonlyArray<OrchestrationCheckpointSummary> =>
152− get(threadDetailAtomFamily(key))?.checkpoints ?? EMPTY_CHECKPOINTS,
153− ).pipe(
154− Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
155− Atom.withLabel(`environment-thread-checkpoints:${key}`),
156− ),
129+ const checkpoints = family("checkpoints", (thread) => thread.checkpoints, EMPTY_CHECKPOINTS);
130+ const pendingApprovals = family(
131+ "pending-approvals",
132+ (thread) => thread.pendingApprovals,
133+ EMPTY_APPROVALS,
134+ );
135+ const pendingUserInputs = family(
136+ "pending-user-inputs",
137+ (thread) => thread.pendingUserInputs,
138+ EMPTY_USER_INPUTS,
139+ );
140+ const runtime = family<ThreadRuntimeSummary | null>("runtime", (thread) => thread.runtime, null);
141+ const latestRun = family<ThreadRunSummary | null>(
142+ "latest-run",
143+ (thread) => thread.latestRun,
144+ null,
157145 );
158146
159− const threadSessionAtomFamily = Atom.family((key: string) =>
160− Atom.make(
161− (get): OrchestrationSession | null => get(threadDetailAtomFamily(key))?.session ?? null,
162− ).pipe(
147+ const status = Atom.family((key: string) =>
148+ Atom.make((get) => get(threadStateValueAtomFamily(key)).status).pipe(
163149 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
164− Atom.withLabel(`environment-thread-session:${key}`),
150+ Atom.withLabel(`environment-thread-status:${key}`),
165151 ),
166152 );
167−
168− const threadLatestTurnAtomFamily = Atom.family((key: string) =>
169− Atom.make(
170− (get): OrchestrationLatestTurn | null => get(threadDetailAtomFamily(key))?.latestTurn ?? null,
171− ).pipe(
153+ const error = Atom.family((key: string) =>
154+ Atom.make((get) => Option.getOrNull(get(threadStateValueAtomFamily(key)).error)).pipe(
172155 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
173− Atom.withLabel(`environment-thread-latest-turn:${key}`),
156+ Atom.withLabel(`environment-thread-error:${key}`),
174157 ),
175158 );
176159
177160 return {
178161 stateAtom: (ref: ScopedThreadRef) => threadStateValueAtomFamily(threadKey(ref)),
162+ threadAtom: (ref: ScopedThreadRef) => scopedThreadProjectionAtomFamily(threadKey(ref)),
163+ visibleTurnItemsAtom: (ref: ScopedThreadRef) => visibleTurnItemsAtomFamily(threadKey(ref)),
179164 detailAtom: (ref: ScopedThreadRef) => threadDetailAtomFamily(threadKey(ref)),
180− statusAtom: (ref: ScopedThreadRef) => threadStatusAtomFamily(threadKey(ref)),
181− errorAtom: (ref: ScopedThreadRef) => threadErrorAtomFamily(threadKey(ref)),
182− messagesAtom: (ref: ScopedThreadRef) => threadMessagesAtomFamily(threadKey(ref)),
183− activitiesAtom: (ref: ScopedThreadRef) => threadActivitiesAtomFamily(threadKey(ref)),
184− proposedPlansAtom: (ref: ScopedThreadRef) => threadProposedPlansAtomFamily(threadKey(ref)),
185− checkpointsAtom: (ref: ScopedThreadRef) => threadCheckpointsAtomFamily(threadKey(ref)),
186− sessionAtom: (ref: ScopedThreadRef) => threadSessionAtomFamily(threadKey(ref)),
187− latestTurnAtom: (ref: ScopedThreadRef) => threadLatestTurnAtomFamily(threadKey(ref)),
165+ statusAtom: (ref: ScopedThreadRef) => status(threadKey(ref)),
166+ errorAtom: (ref: ScopedThreadRef) => error(threadKey(ref)),
167+ messagesAtom: (ref: ScopedThreadRef) => messages(threadKey(ref)),
168+ workEntriesAtom: (ref: ScopedThreadRef) => workEntries(threadKey(ref)),
169+ proposedPlansAtom: (ref: ScopedThreadRef) => proposedPlans(threadKey(ref)),
170+ checkpointsAtom: (ref: ScopedThreadRef) => checkpoints(threadKey(ref)),
171+ pendingApprovalsAtom: (ref: ScopedThreadRef) => pendingApprovals(threadKey(ref)),
172+ pendingUserInputsAtom: (ref: ScopedThreadRef) => pendingUserInputs(threadKey(ref)),
173+ runtimeAtom: (ref: ScopedThreadRef) => runtime(threadKey(ref)),
174+ latestRunAtom: (ref: ScopedThreadRef) => latestRun(threadKey(ref)),
188175 };
189176 }
packages/client-runtime/src/state/threadReducer.test.ts+0−767
@@ -1,767 +0,0 @@
1−import { describe, expect, it } from "vite-plus/test";
2−
3−import {
4− CheckpointRef,
5− CommandId,
6− EventId,
7− MessageId,
8− ProjectId,
9− ProviderInstanceId,
10− ThreadId,
11− TurnId,
12−} from "@t3tools/contracts";
13−import type { OrchestrationThread } from "@t3tools/contracts";
14−
15−import { applyThreadDetailEvent } from "./threadReducer.ts";
16−
17−const baseEventFields = {
18− eventId: EventId.make("event-1"),
19− commandId: null,
20− causationEventId: null,
21− correlationId: null,
22− metadata: {},
23−} as const;
24−
25−const baseThread: OrchestrationThread = {
26− id: ThreadId.make("thread-1"),
27− projectId: ProjectId.make("project-1"),
28− title: "Test Thread",
29− modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
30− runtimeMode: "full-access",
31− interactionMode: "default",
32− branch: null,
33− worktreePath: null,
34− latestTurn: null,
35− createdAt: "2026-04-01T00:00:00.000Z",
36− updatedAt: "2026-04-01T00:00:00.000Z",
37− archivedAt: null,
38− settledOverride: null,
39− settledAt: null,
40− deletedAt: null,
41− messages: [],
42− proposedPlans: [],
43− activities: [],
44− checkpoints: [],
45− session: null,
46−};
47−
48−describe("applyThreadDetailEvent", () => {
49− describe("project events", () => {
50− it("returns unchanged for project.created", () => {
51− const result = applyThreadDetailEvent(baseThread, {
52− ...baseEventFields,
53− sequence: 1,
54− occurredAt: "2026-04-01T01:00:00.000Z",
55− aggregateKind: "project",
56− aggregateId: ProjectId.make("project-1"),
57− type: "project.created",
58− payload: {
59− projectId: ProjectId.make("project-1"),
60− title: "T3 Code",
61− workspaceRoot: "/repo",
62− repositoryIdentity: null,
63− defaultModelSelection: null,
64− scripts: [],
65− createdAt: "2026-04-01T01:00:00.000Z",
66− updatedAt: "2026-04-01T01:00:00.000Z",
67− deletedAt: null,
68− },
69− } as any);
70− expect(result.kind).toBe("unchanged");
71− });
72− });
73−
74− describe("thread.created", () => {
75− it("creates a fresh thread", () => {
76− const result = applyThreadDetailEvent(baseThread, {
77− ...baseEventFields,
78− sequence: 1,
79− occurredAt: "2026-04-01T01:00:00.000Z",
80− aggregateKind: "thread",
81− aggregateId: ThreadId.make("thread-2"),
82− type: "thread.created",
83− payload: {
84− threadId: ThreadId.make("thread-2"),
85− projectId: ProjectId.make("project-1"),
86− title: "New Thread",
87− modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
88− runtimeMode: "full-access",
89− interactionMode: "default",
90− branch: "main",
91− worktreePath: null,
92− createdAt: "2026-04-01T01:00:00.000Z",
93− updatedAt: "2026-04-01T01:00:00.000Z",
94− },
95− });
96−
97− expect(result.kind).toBe("updated");
98− if (result.kind === "updated") {
99− expect(result.thread.id).toBe("thread-2");
100− expect(result.thread.title).toBe("New Thread");
101− expect(result.thread.branch).toBe("main");
102− expect(result.thread.messages).toEqual([]);
103− expect(result.thread.session).toBeNull();
104− }
105− });
106− });
107−
108− describe("thread.deleted", () => {
109− it("returns deleted signal", () => {
110− const result = applyThreadDetailEvent(baseThread, {
111− ...baseEventFields,
112− sequence: 2,
113− occurredAt: "2026-04-01T02:00:00.000Z",
114− aggregateKind: "thread",
115− aggregateId: ThreadId.make("thread-1"),
116− type: "thread.deleted",
117− payload: {
118− threadId: ThreadId.make("thread-1"),
119− deletedAt: "2026-04-01T02:00:00.000Z",
120− },
121− });
122− expect(result.kind).toBe("deleted");
123− });
124− });
125−
126− describe("thread.archived / thread.unarchived", () => {
127− it("sets archivedAt and clears title regeneration", () => {
128− const regeneratingThread: OrchestrationThread = {
129− ...baseThread,
130− titleRegeneration: {
131− requestId: CommandId.make("regenerate-title"),
132− startedAt: "2026-04-01T02:00:00.000Z",
133− },
134− };
135− const result = applyThreadDetailEvent(regeneratingThread, {
136− ...baseEventFields,
137− sequence: 3,
138− occurredAt: "2026-04-01T03:00:00.000Z",
139− aggregateKind: "thread",
140− aggregateId: ThreadId.make("thread-1"),
141− type: "thread.archived",
142− payload: {
143− threadId: ThreadId.make("thread-1"),
144− archivedAt: "2026-04-01T03:00:00.000Z",
145− updatedAt: "2026-04-01T03:00:00.000Z",
146− },
147− });
148−
149− expect(result.kind).toBe("updated");
150− if (result.kind === "updated") {
151− expect(result.thread.archivedAt).toBe("2026-04-01T03:00:00.000Z");
152− expect(result.thread.titleRegeneration).toBeNull();
153− }
154− });
155−
156− it("clears archivedAt", () => {
157− const archivedThread = { ...baseThread, archivedAt: "2026-04-01T03:00:00.000Z" };
158− const result = applyThreadDetailEvent(archivedThread, {
159− ...baseEventFields,
160− sequence: 4,
161− occurredAt: "2026-04-01T04:00:00.000Z",
162− aggregateKind: "thread",
163− aggregateId: ThreadId.make("thread-1"),
164− type: "thread.unarchived",
165− payload: {
166− threadId: ThreadId.make("thread-1"),
167− updatedAt: "2026-04-01T04:00:00.000Z",
168− },
169− });
170−
171− expect(result.kind).toBe("updated");
172− if (result.kind === "updated") {
173− expect(result.thread.archivedAt).toBeNull();
174− }
175− });
176− });
177−
178− describe("thread.settled / thread.unsettled", () => {
179− it("sets the settled override and timestamp", () => {
180− const settledAt = "2026-04-01T05:00:00.000Z";
181− const result = applyThreadDetailEvent(baseThread, {
182− ...baseEventFields,
183− sequence: 5,
184− occurredAt: settledAt,
185− aggregateKind: "thread",
186− aggregateId: ThreadId.make("thread-1"),
187− type: "thread.settled",
188− payload: {
189− threadId: ThreadId.make("thread-1"),
190− settledAt,
191− updatedAt: settledAt,
192− },
193− });
194−
195− expect(result.kind).toBe("updated");
196− if (result.kind === "updated") {
197− expect(result.thread.settledOverride).toBe("settled");
198− expect(result.thread.settledAt).toBe(settledAt);
199− }
200− });
201−
202− it.each([
203− ["user", "active"],
204− ["activity", null],
205− ] as const)("unsettles for %s with override %s", (reason, settledOverride) => {
206− const settledThread: OrchestrationThread = {
207− ...baseThread,
208− settledOverride: "settled",
209− settledAt: "2026-04-01T05:00:00.000Z",
210− };
211− const updatedAt = "2026-04-01T06:00:00.000Z";
212− const result = applyThreadDetailEvent(settledThread, {
213− ...baseEventFields,
214− sequence: 6,
215− occurredAt: updatedAt,
216− aggregateKind: "thread",
217− aggregateId: ThreadId.make("thread-1"),
218− type: "thread.unsettled",
219− payload: {
220− threadId: ThreadId.make("thread-1"),
221− reason,
222− updatedAt,
223− },
224− });
225−
226− expect(result.kind).toBe("updated");
227− if (result.kind === "updated") {
228− expect(result.thread.settledOverride).toBe(settledOverride);
229− expect(result.thread.settledAt).toBeNull();
230− }
231− });
232− });
233−
234− describe("thread.meta-updated", () => {
235− it("patches title and branch", () => {
236− const result = applyThreadDetailEvent(baseThread, {
237− ...baseEventFields,
238− sequence: 5,
239− occurredAt: "2026-04-01T05:00:00.000Z",
240− aggregateKind: "thread",
241− aggregateId: ThreadId.make("thread-1"),
242− type: "thread.meta-updated",
243− payload: {
244− threadId: ThreadId.make("thread-1"),
245− title: "Updated Title",
246− branch: "feature/demo",
247− updatedAt: "2026-04-01T05:00:00.000Z",
248− },
249− });
250−
251− expect(result.kind).toBe("updated");
252− if (result.kind === "updated") {
253− expect(result.thread.title).toBe("Updated Title");
254− expect(result.thread.branch).toBe("feature/demo");
255− // Model selection should be unchanged since it wasn't in the payload
256− expect(result.thread.modelSelection).toEqual(baseThread.modelSelection);
257− }
258− });
259− });
260−
261− describe("thread.message-sent", () => {
262− it("appends a new message", () => {
263− const result = applyThreadDetailEvent(baseThread, {
264− ...baseEventFields,
265− sequence: 6,
266− occurredAt: "2026-04-01T06:00:00.000Z",
267− aggregateKind: "thread",
268− aggregateId: ThreadId.make("thread-1"),
269− type: "thread.message-sent",
270− payload: {
271− threadId: ThreadId.make("thread-1"),
272− messageId: MessageId.make("msg-1"),
273− role: "user",
274− text: "Hello, world!",
275− turnId: null,
276− streaming: false,
277− createdAt: "2026-04-01T06:00:00.000Z",
278− updatedAt: "2026-04-01T06:00:00.000Z",
279− },
280− });
281−
282− expect(result.kind).toBe("updated");
283− if (result.kind === "updated") {
284− expect(result.thread.messages).toHaveLength(1);
285− expect(result.thread.messages[0]?.text).toBe("Hello, world!");
286− }
287− });
288−
289− it("appends text for streaming messages", () => {
290− const threadWithMessage: OrchestrationThread = {
291− ...baseThread,
292− messages: [
293− {
294− id: MessageId.make("msg-2"),
295− role: "assistant",
296− text: "Hello",
297− turnId: TurnId.make("turn-1"),
298− streaming: true,
299− createdAt: "2026-04-01T06:00:00.000Z",
300− updatedAt: "2026-04-01T06:00:00.000Z",
301− },
302− ],
303− };
304−
305− const result = applyThreadDetailEvent(threadWithMessage, {
306− ...baseEventFields,
307− sequence: 7,
308− occurredAt: "2026-04-01T06:01:00.000Z",
309− aggregateKind: "thread",
310− aggregateId: ThreadId.make("thread-1"),
311− type: "thread.message-sent",
312− payload: {
313− threadId: ThreadId.make("thread-1"),
314− messageId: MessageId.make("msg-2"),
315− role: "assistant",
316− text: ", world!",
317− turnId: TurnId.make("turn-1"),
318− streaming: true,
319− createdAt: "2026-04-01T06:00:00.000Z",
320− updatedAt: "2026-04-01T06:01:00.000Z",
321− },
322− });
323−
324− expect(result.kind).toBe("updated");
325− if (result.kind === "updated") {
326− expect(result.thread.messages).toHaveLength(1);
327− expect(result.thread.messages[0]?.text).toBe("Hello, world!");
328− }
329− });
330−
331− it("updates latestTurn for assistant messages with a turn", () => {
332− const result = applyThreadDetailEvent(baseThread, {
333− ...baseEventFields,
334− sequence: 8,
335− occurredAt: "2026-04-01T07:00:00.000Z",
336− aggregateKind: "thread",
337− aggregateId: ThreadId.make("thread-1"),
338− type: "thread.message-sent",
339− payload: {
340− threadId: ThreadId.make("thread-1"),
341− messageId: MessageId.make("msg-3"),
342− role: "assistant",
343− text: "Done.",
344− turnId: TurnId.make("turn-1"),
345− streaming: false,
346− createdAt: "2026-04-01T07:00:00.000Z",
347− updatedAt: "2026-04-01T07:00:00.000Z",
348− },
349− });
350−
351− expect(result.kind).toBe("updated");
352− if (result.kind === "updated") {
353− expect(result.thread.latestTurn?.turnId).toBe("turn-1");
354− expect(result.thread.latestTurn?.state).toBe("completed");
355− expect(result.thread.latestTurn?.assistantMessageId).toBe("msg-3");
356− }
357− });
358−
359− it("keeps latestTurn running for interim assistant messages while the session runs the turn", () => {
360− const threadWithRunningSession: OrchestrationThread = {
361− ...baseThread,
362− session: {
363− threadId: ThreadId.make("thread-1"),
364− status: "running",
365− providerName: "claude",
366− runtimeMode: "full-access",
367− activeTurnId: TurnId.make("turn-1"),
368− lastError: null,
369− updatedAt: "2026-04-01T06:59:00.000Z",
370− },
371− latestTurn: {
372− turnId: TurnId.make("turn-1"),
373− state: "running",
374− requestedAt: "2026-04-01T06:59:00.000Z",
375− startedAt: "2026-04-01T06:59:00.000Z",
376− completedAt: null,
377− assistantMessageId: null,
378− },
379− };
380−
381− const result = applyThreadDetailEvent(threadWithRunningSession, {
382− ...baseEventFields,
383− sequence: 8,
384− occurredAt: "2026-04-01T07:00:00.000Z",
385− aggregateKind: "thread",
386− aggregateId: ThreadId.make("thread-1"),
387− type: "thread.message-sent",
388− payload: {
389− threadId: ThreadId.make("thread-1"),
390− messageId: MessageId.make("msg-3"),
391− role: "assistant",
392− text: "Interim commentary between tool calls.",
393− turnId: TurnId.make("turn-1"),
394− streaming: false,
395− createdAt: "2026-04-01T07:00:00.000Z",
396− updatedAt: "2026-04-01T07:00:00.000Z",
397− },
398− });
399−
400− expect(result.kind).toBe("updated");
401− if (result.kind === "updated") {
402− expect(result.thread.latestTurn?.state).toBe("running");
403− expect(result.thread.latestTurn?.completedAt).toBeNull();
404− }
405− });
406− });
407−
408− describe("thread.session-set", () => {
409− it("settles a running latestTurn when the session leaves the running status", () => {
410− const threadWithRunningTurn: OrchestrationThread = {
411− ...baseThread,
412− latestTurn: {
413− turnId: TurnId.make("turn-1"),
414− state: "running",
415− requestedAt: "2026-04-01T07:00:00.000Z",
416− startedAt: "2026-04-01T07:00:00.000Z",
417− completedAt: null,
418− assistantMessageId: MessageId.make("msg-3"),
419− },
420− };
421−
422− const result = applyThreadDetailEvent(threadWithRunningTurn, {
423− ...baseEventFields,
424− sequence: 9,
425− occurredAt: "2026-04-01T08:00:00.000Z",
426− aggregateKind: "thread",
427− aggregateId: ThreadId.make("thread-1"),
428− type: "thread.session-set",
429− payload: {
430− threadId: ThreadId.make("thread-1"),
431− session: {
432− threadId: ThreadId.make("thread-1"),
433− status: "ready",
434− providerName: "claude",
435− runtimeMode: "full-access",
436− activeTurnId: null,
437− lastError: null,
438− updatedAt: "2026-04-01T08:00:00.000Z",
439− },
440− },
441− });
442−
443− expect(result.kind).toBe("updated");
444− if (result.kind === "updated") {
445− expect(result.thread.latestTurn?.state).toBe("completed");
446− expect(result.thread.latestTurn?.completedAt).toBe("2026-04-01T08:00:00.000Z");
447− }
448− });
449−
450− it("updates session and latestTurn for a running session", () => {
451− const result = applyThreadDetailEvent(baseThread, {
452− ...baseEventFields,
453− sequence: 9,
454− occurredAt: "2026-04-01T08:00:00.000Z",
455− aggregateKind: "thread",
456− aggregateId: ThreadId.make("thread-1"),
457− type: "thread.session-set",
458− payload: {
459− threadId: ThreadId.make("thread-1"),
460− session: {
461− threadId: ThreadId.make("thread-1"),
462− status: "running",
463− providerName: "codex",
464− runtimeMode: "full-access",
465− activeTurnId: TurnId.make("turn-1"),
466− lastError: null,
467− updatedAt: "2026-04-01T08:00:00.000Z",
468− },
469− },
470− });
471−
472− expect(result.kind).toBe("updated");
473− if (result.kind === "updated") {
474− expect(result.thread.session?.status).toBe("running");
475− expect(result.thread.latestTurn?.turnId).toBe("turn-1");
476− expect(result.thread.latestTurn?.state).toBe("running");
477− }
478− });
479− });
480−
481− describe("thread.session-stop-requested", () => {
482− it("marks session as stopped", () => {
483− const threadWithSession: OrchestrationThread = {
484− ...baseThread,
485− session: {
486− threadId: ThreadId.make("thread-1"),
487− status: "running",
488− providerName: "codex",
489− runtimeMode: "full-access",
490− activeTurnId: TurnId.make("turn-1"),
491− lastError: null,
492− updatedAt: "2026-04-01T08:00:00.000Z",
493− },
494− };
495−
496− const result = applyThreadDetailEvent(threadWithSession, {
497− ...baseEventFields,
498− sequence: 10,
499− occurredAt: "2026-04-01T09:00:00.000Z",
500− aggregateKind: "thread",
501− aggregateId: ThreadId.make("thread-1"),
502− type: "thread.session-stop-requested",
503− payload: {
504− threadId: ThreadId.make("thread-1"),
505− createdAt: "2026-04-01T09:00:00.000Z",
506− },
507− });
508−
509− expect(result.kind).toBe("updated");
510− if (result.kind === "updated") {
511− expect(result.thread.session?.status).toBe("stopped");
512− expect(result.thread.session?.activeTurnId).toBeNull();
513− }
514− });
515−
516− it("returns unchanged when no session exists", () => {
517− const result = applyThreadDetailEvent(baseThread, {
518− ...baseEventFields,
519− sequence: 10,
520− occurredAt: "2026-04-01T09:00:00.000Z",
521− aggregateKind: "thread",
522− aggregateId: ThreadId.make("thread-1"),
523− type: "thread.session-stop-requested",
524− payload: {
525− threadId: ThreadId.make("thread-1"),
526− createdAt: "2026-04-01T09:00:00.000Z",
527− },
528− });
529− expect(result.kind).toBe("unchanged");
530− });
531− });
532−
533− describe("thread.proposed-plan-upserted", () => {
534− it("adds a proposed plan", () => {
535− const result = applyThreadDetailEvent(baseThread, {
536− ...baseEventFields,
537− sequence: 11,
538− occurredAt: "2026-04-01T10:00:00.000Z",
539− aggregateKind: "thread",
540− aggregateId: ThreadId.make("thread-1"),
541− type: "thread.proposed-plan-upserted",
542− payload: {
543− threadId: ThreadId.make("thread-1"),
544− proposedPlan: {
545− id: "plan-1",
546− turnId: TurnId.make("turn-1"),
547− planMarkdown: "## Plan\n- Do stuff",
548− implementedAt: null,
549− implementationThreadId: null,
550− createdAt: "2026-04-01T10:00:00.000Z",
551− updatedAt: "2026-04-01T10:00:00.000Z",
552− },
553− },
554− });
555−
556− expect(result.kind).toBe("updated");
557− if (result.kind === "updated") {
558− expect(result.thread.proposedPlans).toHaveLength(1);
559− expect(result.thread.proposedPlans[0]?.id).toBe("plan-1");
560− }
561− });
562− });
563−
564− describe("thread.activity-appended", () => {
565− it("adds an activity", () => {
566− const result = applyThreadDetailEvent(baseThread, {
567− ...baseEventFields,
568− sequence: 12,
569− occurredAt: "2026-04-01T11:00:00.000Z",
570− aggregateKind: "thread",
571− aggregateId: ThreadId.make("thread-1"),
572− type: "thread.activity-appended",
573− payload: {
574− threadId: ThreadId.make("thread-1"),
575− activity: {
576− id: EventId.make("activity-1"),
577− tone: "tool",
578− kind: "file-edit",
579− summary: "Edited src/index.ts",
580− payload: {},
581− turnId: TurnId.make("turn-1"),
582− createdAt: "2026-04-01T11:00:00.000Z",
583− },
584− },
585− });
586−
587− expect(result.kind).toBe("updated");
588− if (result.kind === "updated") {
589− expect(result.thread.activities).toHaveLength(1);
590− expect(result.thread.activities[0]?.kind).toBe("file-edit");
591− }
592− });
593−
594− it("preserves the complete activity history when live events arrive", () => {
595− const existingActivities = Array.from({ length: 129 }, (_, index) => ({
596− id: EventId.make(`activity-${index}`),
597− tone: "tool" as const,
598− kind: "command",
599− summary: `Ran command ${index}`,
600− payload: {},
601− turnId: TurnId.make("turn-1"),
602− sequence: index,
603− createdAt: "2026-04-01T11:00:00.000Z",
604− }));
605− const result = applyThreadDetailEvent(
606− { ...baseThread, activities: existingActivities },
607− {
608− ...baseEventFields,
609− sequence: 130,
610− occurredAt: "2026-04-01T11:01:00.000Z",
611− aggregateKind: "thread",
612− aggregateId: ThreadId.make("thread-1"),
613− type: "thread.activity-appended",
614− payload: {
615− threadId: ThreadId.make("thread-1"),
616− activity: {
617− id: EventId.make("activity-129"),
618− tone: "tool",
619− kind: "command",
620− summary: "Ran command 129",
621− payload: {},
622− turnId: TurnId.make("turn-1"),
623− sequence: 129,
624− createdAt: "2026-04-01T11:01:00.000Z",
625− },
626− },
627− },
628− );
629−
630− expect(result.kind).toBe("updated");
631− if (result.kind === "updated") {
632− expect(result.thread.activities).toHaveLength(130);
633− expect(result.thread.activities[0]?.id).toBe("activity-0");
634− }
635− });
636− });
637−
638− describe("thread.turn-diff-completed", () => {
639− it("adds a checkpoint and updates latestTurn", () => {
640− const result = applyThreadDetailEvent(baseThread, {
641− ...baseEventFields,
642− sequence: 13,
643− occurredAt: "2026-04-01T12:00:00.000Z",
644− aggregateKind: "thread",
645− aggregateId: ThreadId.make("thread-1"),
646− type: "thread.turn-diff-completed",
647− payload: {
648− threadId: ThreadId.make("thread-1"),
649− turnId: TurnId.make("turn-1"),
650− checkpointTurnCount: 1,
651− checkpointRef: CheckpointRef.make("ref-1"),
652− status: "ready",
653− files: [],
654− assistantMessageId: MessageId.make("msg-3"),
655− completedAt: "2026-04-01T12:00:00.000Z",
656− },
657− });
658−
659− expect(result.kind).toBe("updated");
660− if (result.kind === "updated") {
661− expect(result.thread.checkpoints).toHaveLength(1);
662− expect(result.thread.latestTurn?.turnId).toBe("turn-1");
663− expect(result.thread.latestTurn?.state).toBe("completed");
664− }
665− });
666− });
667−
668− describe("thread.reverted", () => {
669− it("filters entities to retained turns", () => {
670− const threadWithData: OrchestrationThread = {
671− ...baseThread,
672− messages: [
673− {
674− id: MessageId.make("msg-1"),
675− role: "user",
676− text: "First",
677− turnId: null,
678− streaming: false,
679− createdAt: "2026-04-01T01:00:00.000Z",
680− updatedAt: "2026-04-01T01:00:00.000Z",
681− },
682− {
683− id: MessageId.make("msg-2"),
684− role: "assistant",
685− text: "Response 1",
686− turnId: TurnId.make("turn-1"),
687− streaming: false,
688− createdAt: "2026-04-01T02:00:00.000Z",
689− updatedAt: "2026-04-01T02:00:00.000Z",
690− },
691− {
692− id: MessageId.make("msg-3"),
693− role: "assistant",
694− text: "Response 2",
695− turnId: TurnId.make("turn-2"),
696− streaming: false,
697− createdAt: "2026-04-01T03:00:00.000Z",
698− updatedAt: "2026-04-01T03:00:00.000Z",
699− },
700− ],
701− checkpoints: [
702− {
703− turnId: TurnId.make("turn-1"),
704− checkpointTurnCount: 1,
705− checkpointRef: CheckpointRef.make("ref-1"),
706− status: "ready",
707− files: [],
708− assistantMessageId: MessageId.make("msg-2"),
709− completedAt: "2026-04-01T02:00:00.000Z",
710− },
711− {
712− turnId: TurnId.make("turn-2"),
713− checkpointTurnCount: 2,
714− checkpointRef: CheckpointRef.make("ref-2"),
715− status: "ready",
716− files: [],
717− assistantMessageId: MessageId.make("msg-3"),
718− completedAt: "2026-04-01T03:00:00.000Z",
719− },
720− ],
721− };
722−
723− const result = applyThreadDetailEvent(threadWithData, {
724− ...baseEventFields,
725− sequence: 14,
726− occurredAt: "2026-04-01T04:00:00.000Z",
727− aggregateKind: "thread",
728− aggregateId: ThreadId.make("thread-1"),
729− type: "thread.reverted",
730− payload: {
731− threadId: ThreadId.make("thread-1"),
732− turnCount: 1,
733− },
734− });
735−
736− expect(result.kind).toBe("updated");
737− if (result.kind === "updated") {
738− // turn-2 checkpoint is filtered out (turnCount 2 > revert target 1)
739− expect(result.thread.checkpoints).toHaveLength(1);
740− expect(result.thread.checkpoints[0]?.turnId).toBe("turn-1");
741− // msg-3 (turn-2) is filtered, msg-1 (no turn) and msg-2 (turn-1) remain
742− expect(result.thread.messages).toHaveLength(2);
743− expect(result.thread.latestTurn?.turnId).toBe("turn-1");
744− }
745− });
746− });
747−
748− describe("no-op events", () => {
749− it("returns unchanged for approval-response-requested", () => {
750− const result = applyThreadDetailEvent(baseThread, {
751− ...baseEventFields,
752− sequence: 15,
753− occurredAt: "2026-04-01T13:00:00.000Z",
754− aggregateKind: "thread",
755− aggregateId: ThreadId.make("thread-1"),
756− type: "thread.approval-response-requested",
757− payload: {
758− threadId: ThreadId.make("thread-1"),
759− requestId: "req-1",
760− decision: "approve",
761− createdAt: "2026-04-01T13:00:00.000Z",
762− },
763− } as any);
764− expect(result.kind).toBe("unchanged");
765− });
766− });
767−});
packages/client-runtime/src/state/threadReducer.ts+0−599
@@ -1,599 +0,0 @@
1−import { pipe } from "effect/Function";
2−import * as Arr from "effect/Array";
3−import * as O from "effect/Order";
4−import type {
5− MessageId,
6− OrchestrationCheckpointSummary,
7− OrchestrationEvent,
8− OrchestrationLatestTurn,
9− OrchestrationMessage,
10− OrchestrationSession,
11− OrchestrationThread,
12− OrchestrationThreadActivity,
13− TurnId,
14−} from "@t3tools/contracts";
15−
16−export type ThreadDetailReducerResult =
17− | { readonly kind: "updated"; readonly thread: OrchestrationThread }
18− | { readonly kind: "deleted" }
19− | { readonly kind: "unchanged" };
20−
21−const proposedPlanOrder = O.combine<OrchestrationThread["proposedPlans"][number]>(
22− O.mapInput(O.String, (p) => p.createdAt),
23− O.mapInput(O.String, (p) => p.id),
24−);
25−
26−const checkpointOrder = O.mapInput(
27− O.Number,
28− (cp: OrchestrationThread["checkpoints"][number]) =>
29− cp.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER,
30−);
31−
32−const activityOrder = O.combineAll<OrchestrationThreadActivity>([
33− O.mapInput(O.Number, (a) => a.sequence ?? Number.MAX_SAFE_INTEGER),
34− O.mapInput(O.String, (a) => a.createdAt),
35− O.mapInput(O.String, (a) => a.id),
36−]);
37−
38−/**
39− * Apply a single orchestration event to an `OrchestrationThread`, returning
40− * the updated thread, a deletion signal, or an "unchanged" marker when the
41− * event doesn't affect this thread.
42− *
43− * This is a pure reducer operating on contract types. UI-specific mapping
44− * (e.g. resolving attachment preview URLs, normalising model slugs, adding
45− * scoped fields like `environmentId`) is the caller's responsibility.
46− */
47−export function applyThreadDetailEvent(
48− thread: OrchestrationThread,
49− event: OrchestrationEvent,
50−): ThreadDetailReducerResult {
51− switch (event.type) {
52− // ── Project events (irrelevant to thread detail) ────────────────
53− case "project.created":
54− case "project.meta-updated":
55− case "project.deleted":
56− return { kind: "unchanged" };
57−
58− // ── Thread lifecycle ────────────────────────────────────────────
59− case "thread.created":
60− return {
61− kind: "updated",
62− thread: {
63− id: event.payload.threadId,
64− projectId: event.payload.projectId,
65− title: event.payload.title,
66− modelSelection: event.payload.modelSelection,
67− runtimeMode: event.payload.runtimeMode,
68− interactionMode: event.payload.interactionMode,
69− branch: event.payload.branch,
70− worktreePath: event.payload.worktreePath,
71− latestTurn: null,
72− createdAt: event.payload.createdAt,
73− updatedAt: event.payload.updatedAt,
74− archivedAt: null,
75− settledOverride: null,
76− settledAt: null,
77− snoozedUntil: null,
78− snoozedAt: null,
79− deletedAt: null,
80− messages: [],
81− proposedPlans: [],
82− activities: [],
83− checkpoints: [],
84− session: null,
85− },
86− };
87−
88− case "thread.deleted":
89− return { kind: "deleted" };
90−
91− case "thread.archived":
92− return {
93− kind: "updated",
94− thread: {
95− ...thread,
96− archivedAt: event.payload.archivedAt,
97− titleRegeneration: null,
98− updatedAt: event.payload.updatedAt,
99− },
100− };
101−
102− case "thread.unarchived":
103− return {
104− kind: "updated",
105− thread: { ...thread, archivedAt: null, updatedAt: event.payload.updatedAt },
106− };
107−
108− case "thread.settled":
109− return {
110− kind: "updated",
111− thread: {
112− ...thread,
113− settledOverride: "settled",
114− settledAt: event.payload.settledAt,
115− updatedAt: event.payload.updatedAt,
116− },
117− };
118−
119− case "thread.unsettled":
120− return {
121− kind: "updated",
122− thread: {
123− ...thread,
124− settledOverride: event.payload.reason === "user" ? "active" : null,
125− settledAt: null,
126− updatedAt: event.payload.updatedAt,
127− },
128− };
129−
130− case "thread.snoozed":
131− return {
132− kind: "updated",
133− thread: {
134− ...thread,
135− snoozedUntil: event.payload.snoozedUntil,
136− snoozedAt: event.payload.snoozedAt,
137− updatedAt: event.payload.updatedAt,
138− },
139− };
140−
141− case "thread.unsnoozed":
142− return {
143− kind: "updated",
144− thread: {
145− ...thread,
146− snoozedUntil: null,
147− snoozedAt: null,
148− updatedAt: event.payload.updatedAt,
149− },
150− };
151−
152− // ── Thread metadata ─────────────────────────────────────────────
153− case "thread.meta-updated":
154− return {
155− kind: "updated",
156− thread: {
157− ...thread,
158− ...(event.payload.title !== undefined ? { title: event.payload.title } : {}),
159− ...(event.payload.titleRegeneration !== undefined
160− ? { titleRegeneration: event.payload.titleRegeneration }
161− : {}),
162− ...(event.payload.modelSelection !== undefined
163− ? { modelSelection: event.payload.modelSelection }
164− : {}),
165− ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}),
166− ...(event.payload.worktreePath !== undefined
167− ? { worktreePath: event.payload.worktreePath }
168− : {}),
169− updatedAt: event.payload.updatedAt,
170− },
171− };
172−
173− case "thread.runtime-mode-set":
174− return {
175− kind: "updated",
176− thread: {
177− ...thread,
178− runtimeMode: event.payload.runtimeMode,
179− updatedAt: event.payload.updatedAt,
180− },
181− };
182−
183− case "thread.interaction-mode-set":
184− return {
185− kind: "updated",
186− thread: {
187− ...thread,
188− interactionMode: event.payload.interactionMode,
189− updatedAt: event.payload.updatedAt,
190− },
191− };
192−
193− // ── Turn lifecycle ──────────────────────────────────────────────
194− case "thread.turn-start-requested":
195− return {
196− kind: "updated",
197− thread: {
198− ...thread,
199− ...(event.payload.modelSelection !== undefined
200− ? { modelSelection: event.payload.modelSelection }
201− : {}),
202− runtimeMode: event.payload.runtimeMode,
203− interactionMode: event.payload.interactionMode,
204− updatedAt: event.occurredAt,
205− },
206− };
207−
208− case "thread.turn-interrupt-requested": {
209− if (event.payload.turnId === undefined) {
210− return { kind: "unchanged" };
211− }
212− const latestTurn = thread.latestTurn;
213− if (latestTurn === null || latestTurn.turnId !== event.payload.turnId) {
214− return { kind: "unchanged" };
215− }
216− return {
217− kind: "updated",
218− thread: {
219− ...thread,
220− latestTurn: {
221− ...latestTurn,
222− state: "interrupted",
223− startedAt: latestTurn.startedAt ?? event.payload.createdAt,
224− completedAt: latestTurn.completedAt ?? event.payload.createdAt,
225− },
226− updatedAt: event.occurredAt,
227− },
228− };
229− }
230−
231− // ── Messages ────────────────────────────────────────────────────
232− case "thread.message-sent": {
233− const message: OrchestrationMessage = {
234− id: event.payload.messageId,
235− role: event.payload.role,
236− text: event.payload.text,
237− ...(event.payload.attachments !== undefined
238− ? { attachments: event.payload.attachments }
239− : {}),
240− turnId: event.payload.turnId,
241− streaming: event.payload.streaming,
242− createdAt: event.payload.createdAt,
243− updatedAt: event.payload.updatedAt,
244− };
245−
246− const existingMessage = thread.messages.find((entry) => entry.id === message.id);
247− const messages = existingMessage
248− ? Arr.map(thread.messages, (entry) =>
249− entry.id !== message.id
250− ? entry
251− : {
252− ...entry,
253− text: message.streaming
254− ? `${entry.text}${message.text}`
255− : message.text.length > 0
256− ? message.text
257− : entry.text,
258− streaming: message.streaming,
259− ...(message.turnId !== undefined ? { turnId: message.turnId } : {}),
260− ...(message.streaming ? {} : { updatedAt: message.updatedAt }),
261− ...(message.attachments !== undefined
262− ? { attachments: message.attachments }
263− : {}),
264− },
265− )
266− : Arr.append(thread.messages, message);
267− // Update latestTurn for assistant messages bound to a turn. A completed
268− // assistant message only settles the turn once the session is no longer
269− // running it — providers may emit several assistant messages per turn
270− // (commentary between tool calls), and the turn must stay unsettled
271− // until the provider reports turn end.
272− const turnStillRunning =
273− event.payload.turnId !== null &&
274− thread.session?.status === "running" &&
275− thread.session.activeTurnId === event.payload.turnId;
276− const settlesTurn = !event.payload.streaming && !turnStillRunning;
277− const latestTurn: OrchestrationThread["latestTurn"] =
278− event.payload.role === "assistant" &&
279− event.payload.turnId !== null &&
280− (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId)
281− ? {
282− turnId: event.payload.turnId,
283− state: settlesTurn
284− ? thread.latestTurn?.state === "interrupted"
285− ? "interrupted"
286− : thread.latestTurn?.state === "error"
287− ? "error"
288− : "completed"
289− : "running",
290− requestedAt:
291− thread.latestTurn?.turnId === event.payload.turnId
292− ? thread.latestTurn.requestedAt
293− : event.payload.createdAt,
294− startedAt:
295− thread.latestTurn?.turnId === event.payload.turnId
296− ? (thread.latestTurn.startedAt ?? event.payload.createdAt)
297− : event.payload.createdAt,
298− completedAt: settlesTurn
299− ? event.payload.updatedAt
300− : thread.latestTurn?.turnId === event.payload.turnId
301− ? (thread.latestTurn.completedAt ?? null)
302− : null,
303− assistantMessageId: event.payload.messageId,
304− }
305− : thread.latestTurn;
306−
307− // Rebind checkpoint assistant message IDs for assistant messages.
308− const checkpoints =
309− event.payload.role === "assistant" && event.payload.turnId !== null
310− ? rebindCheckpointAssistantMessage(
311− thread.checkpoints,
312− event.payload.turnId,
313− event.payload.messageId,
314− )
315− : thread.checkpoints;
316−
317− return {
318− kind: "updated",
319− thread: {
320− ...thread,
321− messages,
322− checkpoints,
323− latestTurn,
324− updatedAt: event.occurredAt,
325− },
326− };
327− }
328−
329− // ── Session ─────────────────────────────────────────────────────
330− case "thread.session-set": {
331− // Leaving the "running" session status is the turn-end signal: settle a
332− // still-running latest turn so its duration reflects the whole turn.
333− const settledTurnState = settledTurnStateForSessionStatus(event.payload.session.status);
334− const latestTurn: OrchestrationLatestTurn | null =
335− event.payload.session.status === "running" && event.payload.session.activeTurnId !== null
336− ? {
337− turnId: event.payload.session.activeTurnId,
338− state: "running",
339− requestedAt:
340− thread.latestTurn?.turnId === event.payload.session.activeTurnId
341− ? thread.latestTurn.requestedAt
342− : event.payload.session.updatedAt,
343− startedAt:
344− thread.latestTurn?.turnId === event.payload.session.activeTurnId
345− ? (thread.latestTurn.startedAt ?? event.payload.session.updatedAt)
346− : event.payload.session.updatedAt,
347− completedAt: null,
348− assistantMessageId:
349− thread.latestTurn?.turnId === event.payload.session.activeTurnId
350− ? thread.latestTurn.assistantMessageId
351− : null,
352− }
353− : thread.latestTurn !== null &&
354− thread.latestTurn.state === "running" &&
355− settledTurnState !== null
356− ? {
357− ...thread.latestTurn,
358− state: settledTurnState,
359− // A running turn's completedAt can only hold a mid-turn
360− // placeholder checkpoint timestamp — the session leaving
361− // "running" is the authoritative turn end.
362− completedAt: event.payload.session.updatedAt,
363− }
364− : thread.latestTurn;
365−
366− return {
367− kind: "updated",
368− thread: {
369− ...thread,
370− session: event.payload.session,
371− latestTurn,
372− updatedAt: event.occurredAt,
373− },
374− };
375− }
376−
377− case "thread.session-stop-requested":
378− return thread.session === null
379− ? { kind: "unchanged" }
380− : {
381− kind: "updated",
382− thread: {
383− ...thread,
384− session: {
385− ...thread.session,
386− status: "stopped",
387− activeTurnId: null,
388− updatedAt: event.payload.createdAt,
389− },
390− updatedAt: event.occurredAt,
391− },
392− };
393−
394− // ── Proposed plans ──────────────────────────────────────────────
395− case "thread.proposed-plan-upserted": {
396− const proposedPlan = event.payload.proposedPlan;
397−
398− const proposedPlans = pipe(
399− thread.proposedPlans,
400− Arr.filter((entry) => entry.id !== proposedPlan.id),
401− Arr.append(proposedPlan),
402− Arr.sort(proposedPlanOrder),
403− );
404−
405− return {
406− kind: "updated",
407− thread: { ...thread, proposedPlans, updatedAt: event.occurredAt },
408− };
409− }
410−
411− // ── Checkpoints / turn diffs ────────────────────────────────────
412− case "thread.turn-diff-completed": {
413− const checkpoint: OrchestrationCheckpointSummary = {
414− turnId: event.payload.turnId,
415− checkpointTurnCount: event.payload.checkpointTurnCount,
416− checkpointRef: event.payload.checkpointRef,
417− status: event.payload.status,
418− files: event.payload.files,
419− assistantMessageId: event.payload.assistantMessageId,
420− completedAt: event.payload.completedAt,
421− };
422−
423− const existing = thread.checkpoints.find((entry) => entry.turnId === checkpoint.turnId);
424− // Don't overwrite a non-missing checkpoint with a missing one.
425− if (existing && existing.status !== "missing" && checkpoint.status === "missing") {
426− return { kind: "unchanged" };
427− }
428−
429− const checkpoints = pipe(
430− thread.checkpoints,
431− Arr.filter((entry) => entry.turnId !== checkpoint.turnId),
432− Arr.append(checkpoint),
433− Arr.sort(checkpointOrder),
434− );
435−
436− // Mid-turn diff updates produce placeholder checkpoints; record the
437− // checkpoint, but don't settle a turn its session is still running.
438− const diffTurnStillRunning =
439− thread.session?.status === "running" &&
440− thread.session.activeTurnId === event.payload.turnId;
441− const latestTurn =
442− !diffTurnStillRunning &&
443− (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId)
444− ? {
445− turnId: event.payload.turnId,
446− state: checkpointStatusToTurnState(event.payload.status),
447− requestedAt: thread.latestTurn?.requestedAt ?? event.payload.completedAt,
448− startedAt: thread.latestTurn?.startedAt ?? event.payload.completedAt,
449− completedAt: event.payload.completedAt,
450− assistantMessageId: event.payload.assistantMessageId,
451− }
452− : thread.latestTurn;
453−
454− return {
455− kind: "updated",
456− thread: { ...thread, checkpoints, latestTurn, updatedAt: event.occurredAt },
457− };
458− }
459−
460− // ── Revert ──────────────────────────────────────────────────────
461− case "thread.reverted": {
462− const checkpoints = pipe(
463− thread.checkpoints,
464− Arr.filter(
465− (entry) =>
466− entry.checkpointTurnCount !== undefined &&
467− entry.checkpointTurnCount <= event.payload.turnCount,
468− ),
469− Arr.sort(checkpointOrder),
470− );
471−
472− const retainedTurnIds = new Set(Arr.map(checkpoints, (entry) => entry.turnId));
473− const messages = retainMessagesAfterRevert(thread.messages, retainedTurnIds);
474− const proposedPlans = pipe(
475− thread.proposedPlans,
476− Arr.filter((plan) => plan.turnId === null || retainedTurnIds.has(plan.turnId)),
477− );
478− const activities = pipe(
479− thread.activities,
480− Arr.filter((activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId)),
481− );
482− const latestCheckpoint = checkpoints.at(-1) ?? null;
483−
484− return {
485− kind: "updated",
486− thread: {
487− ...thread,
488− checkpoints,
489− messages,
490− proposedPlans,
491− activities,
492− latestTurn:
493− latestCheckpoint === null
494− ? null
495− : {
496− turnId: latestCheckpoint.turnId,
497− state: checkpointStatusToTurnState(
498− latestCheckpoint.status as "ready" | "missing" | "error",
499− ),
500− requestedAt: latestCheckpoint.completedAt,
501− startedAt: latestCheckpoint.completedAt,
502− completedAt: latestCheckpoint.completedAt,
503− assistantMessageId: latestCheckpoint.assistantMessageId ?? null,
504− },
505− updatedAt: event.occurredAt,
506− },
507− };
508− }
509−
510− // ── Activities ──────────────────────────────────────────────────
511− case "thread.activity-appended": {
512− const activities = pipe(
513− thread.activities,
514− Arr.filter((activity) => activity.id !== event.payload.activity.id),
515− Arr.append(event.payload.activity),
516− Arr.sort(activityOrder),
517− );
518−
519− return {
520− kind: "updated",
521− thread: { ...thread, activities, updatedAt: event.occurredAt },
522− };
523− }
524−
525− // ── Events that don't mutate thread state directly ──────────────
526− case "thread.approval-response-requested":
527− case "thread.user-input-response-requested":
528− case "thread.checkpoint-revert-requested":
529− return { kind: "unchanged" };
530− }
531−
532− // Forward-compatible: ignore unrecognized event types.
533− return { kind: "unchanged" };
534−}
535−
536−// ── Helpers ──────────────────────────────────────────────────────────
537−
538−/**
539− * Turn state to settle a still-running latest turn with when its session
540− * leaves the "running" status, or null while the session is (re)starting or
541− * running and the turn must stay unsettled.
542− */
543−function settledTurnStateForSessionStatus(
544− status: OrchestrationSession["status"],
545−): "completed" | "interrupted" | "error" | null {
546− switch (status) {
547− case "idle":
548− case "ready":
549− return "completed";
550− case "error":
551− return "error";
552− case "interrupted":
553− case "stopped":
554− return "interrupted";
555− case "starting":
556− case "running":
557− return null;
558− }
559−}
560−
561−function checkpointStatusToTurnState(
562− status: "ready" | "missing" | "error",
563−): OrchestrationLatestTurn["state"] {
564− switch (status) {
565− case "ready":
566− return "completed";
567− case "error":
568− return "error";
569− case "missing":
570− return "completed";
571− }
572−}
573−
574−function rebindCheckpointAssistantMessage(
575− checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>,
576− turnId: TurnId,
577− messageId: MessageId,
578−): OrchestrationCheckpointSummary[] {
579− return Arr.map(checkpoints, (entry) =>
580− entry.turnId === turnId ? { ...entry, assistantMessageId: messageId } : entry,
581− );
582−}
583−
584−function retainMessagesAfterRevert(
585− messages: ReadonlyArray<OrchestrationMessage>,
586− retainedTurnIds: ReadonlySet<string>,
587−): OrchestrationMessage[] {
588− // Keep messages that belong to a retained turn, plus system messages and
589− // messages without a turn binding (pre-turn-0 user messages).
590− return Arr.filter(messages, (message) => {
591− if (message.role === "system") {
592− return true;
593− }
594− if (message.turnId === null) {
595− return true;
596− }
597− return retainedTurnIds.has(message.turnId);
598− });
599−}
packages/client-runtime/src/state/threadShell.ts+10−10
@@ -1,7 +1,7 @@
11 import type {
22 EnvironmentId,
3− OrchestrationShellSnapshot,
4− OrchestrationThreadShell,
3+ OrchestrationV2ShellSnapshot,
4+ OrchestrationV2ThreadShell,
55 ProjectId,
66 ScopedProjectRef,
77 ScopedThreadRef,
@@ -10,7 +10,7 @@ import type { · 2 unmodified lines
1010 import { Atom } from "effect/unstable/reactivity";
1111
1212 import type { EnvironmentThreadShell } from "./models.ts";
13−import { scopeThreadShell } from "./models.ts";
13+import { presentThreadShell } from "./models.ts";
1414 import type { EnvironmentCatalogState } from "./connections.ts";
1515 import {
1616 arrayElementsEqual,
@@ -21,9 +21,9 @@ import { · 4 unmodified lines
2121 threadRefsEqual,
2222 } from "./entities.ts";
2323
24−const EMPTY_THREADS: ReadonlyArray<OrchestrationThreadShell> = Object.freeze([]);
24+const EMPTY_THREADS: ReadonlyArray<OrchestrationV2ThreadShell> = Object.freeze([]);
2525 const EMPTY_SCOPED_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]);
26−const EMPTY_THREAD_INDEX: ReadonlyMap<ThreadId, OrchestrationThreadShell> = new Map();
26+const EMPTY_THREAD_INDEX: ReadonlyMap<ThreadId, OrchestrationV2ThreadShell> = new Map();
2727 const EMPTY_THREAD_REFS_BY_PROJECT: ReadonlyMap<
2828 ProjectId,
2929 ReadonlyArray<ScopedThreadRef>
@@ -33,17 +33,17 @@ export function createEnvironmentThreadShellAtoms(input: { · 3 unmodified lines
3333 readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>;
3434 readonly snapshotAtom: (
3535 environmentId: EnvironmentId,
36− ) => Atom.Atom<OrchestrationShellSnapshot | null>;
36+ ) => Atom.Atom<OrchestrationV2ShellSnapshot | null>;
3737 }) {
3838 const environmentThreadsAtom = Atom.family((environmentId: EnvironmentId) =>
3939 Atom.make(
40− (get): ReadonlyArray<OrchestrationThreadShell> =>
40+ (get): ReadonlyArray<OrchestrationV2ThreadShell> =>
4141 get(input.snapshotAtom(environmentId))?.threads ?? EMPTY_THREADS,
4242 ).pipe(Atom.withLabel(`environment-threads:${environmentId}`)),
4343 );
4444
4545 const environmentThreadIndexAtom = Atom.family((environmentId: EnvironmentId) =>
46− Atom.make((get): ReadonlyMap<ThreadId, OrchestrationThreadShell> => {
46+ Atom.make((get): ReadonlyMap<ThreadId, OrchestrationV2ThreadShell> => {
4747 const threads = get(environmentThreadsAtom(environmentId));
4848 if (threads.length === 0) {
4949 return EMPTY_THREAD_INDEX;
@@ -102,7 +102,7 @@ export function createEnvironmentThreadShellAtoms(input: { · 52 unmodified lines
102102
103103 const threadShellAtomFamily = Atom.family((key: string) => {
104104 const ref = parseThreadKey(key);
105− let previousSource: OrchestrationThreadShell | null = null;
105+ let previousSource: OrchestrationV2ThreadShell | null = null;
106106 let previousValue: EnvironmentThreadShell | null = null;
107107 return Atom.make((get) => {
108108 const source = get(environmentThreadIndexAtom(ref.environmentId)).get(ref.threadId) ?? null;
@@ -110,7 +110,7 @@ export function createEnvironmentThreadShellAtoms(input: { · 1 unmodified line
110110 return previousValue;
111111 }
112112 previousSource = source;
113− previousValue = source === null ? null : scopeThreadShell(ref.environmentId, source);
113+ previousValue = source === null ? null : presentThreadShell(ref.environmentId, source);
114114 return previousValue;
115115 }).pipe(Atom.withLabel(`environment-thread-shell:${key}`));
116116 });
packages/client-runtime/src/state/threads-sync.test.ts+64−562
@@ -1,41 +1,29 @@
11 import {
22 EnvironmentId,
33 EventId,
4− ORCHESTRATION_WS_METHODS,
5− ProjectId,
6− ProviderInstanceId,
7− ThreadId,
8− TurnId,
9− type OrchestrationThread,
10− type OrchestrationThreadDetailSnapshot,
11− type OrchestrationThreadStreamItem,
4+ ORCHESTRATION_V2_WS_METHODS,
5+ type OrchestrationV2ThreadStreamItem,
126 } from "@t3tools/contracts";
137 import { describe, expect, it } from "@effect/vitest";
8+import * as DateTime from "effect/DateTime";
149 import * as Effect from "effect/Effect";
1510 import * as Option from "effect/Option";
1611 import * as Queue from "effect/Queue";
17−import * as Ref from "effect/Ref";
1812 import * as Stream from "effect/Stream";
1913 import * as SubscriptionRef from "effect/SubscriptionRef";
20−import * as TestClock from "effect/testing/TestClock";
2114
22−import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
2315 import {
2416 AVAILABLE_CONNECTION_STATE,
2517 PrimaryConnectionTarget,
2618 type PreparedConnection,
27− type SupervisorConnectionState,
2819 } from "../connection/model.ts";
2920 import * as ConnectionWakeups from "../connection/wakeups.ts";
3021 import * as EnvironmentSupervisor from "../connection/supervisor.ts";
3122 import * as Persistence from "../platform/persistence.ts";
3223 import * as RpcSession from "../rpc/session.ts";
33−import {
34− EMPTY_ENVIRONMENT_THREAD_STATE,
35− makeEnvironmentThreadState,
36− ThreadSnapshotLoader,
37− type EnvironmentThreadState,
38−} from "./threads.ts";
24+import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
25+import { v2Projection, v2ThreadId } from "./orchestrationV2TestFixtures.ts";
26+import { makeEnvironmentThreadState } from "./threads.ts";
3927
4028 const TARGET = new PrimaryConnectionTarget({
4129 environmentId: EnvironmentId.make("environment-1"),
@@ -43,559 +31,73 @@ const TARGET = new PrimaryConnectionTarget({ · 1 unmodified line
4331 httpBaseUrl: "https://environment.example.test",
4432 wsBaseUrl: "wss://environment.example.test",
4533 });
46−const THREAD_ID = ThreadId.make("thread-1");
47−const CACHED_SNAPSHOT_SEQUENCE = 7;
48−const PREPARED: PreparedConnection = {
49− environmentId: TARGET.environmentId,
50− label: TARGET.label,
51− httpBaseUrl: TARGET.httpBaseUrl,
52− socketUrl: TARGET.wsBaseUrl,
53− httpAuthorization: null,
54− target: TARGET,
55−};
56−const BASE_THREAD: OrchestrationThread = {
57− id: THREAD_ID,
58− projectId: ProjectId.make("project-1"),
59− title: "Cached thread",
60− modelSelection: {
61− instanceId: ProviderInstanceId.make("codex"),
62− model: "gpt-5.4",
63− },
64− runtimeMode: "full-access",
65− interactionMode: "default",
66− branch: "main",
67− worktreePath: null,
68− latestTurn: null,
69− createdAt: "2026-04-01T00:00:00.000Z",
70− updatedAt: "2026-04-01T00:00:00.000Z",
71− archivedAt: null,
72− settledOverride: null,
73− settledAt: null,
74− deletedAt: null,
75− messages: [],
76− proposedPlans: [],
77− activities: [],
78− checkpoints: [],
79− session: null,
80−};
81−const ACTIVE_THREAD: OrchestrationThread = {
82− ...BASE_THREAD,
83− latestTurn: {
84− turnId: TurnId.make("turn-1"),
85− state: "running",
86− requestedAt: "2026-04-01T00:01:00.000Z",
87− startedAt: "2026-04-01T00:01:00.000Z",
88− completedAt: null,
89− assistantMessageId: null,
90− },
91− session: {
92− threadId: THREAD_ID,
93− status: "running",
94− providerName: "codex",
95− runtimeMode: "full-access",
96− activeTurnId: TurnId.make("turn-1"),
97− lastError: null,
98− updatedAt: "2026-04-01T00:01:00.000Z",
99− },
100−};
101−
102−type TestThreadInput = OrchestrationThreadStreamItem | Error;
103−
104−function testSession(
105− client: WsRpcProtocolClient,
106− options?: { readonly completionMarker?: boolean },
107−): RpcSession.RpcSession {
108− return {
109− client,
110− initialConfig: Effect.succeed(
111− options?.completionMarker === true
112− ? ({ threadResumeCompletionMarker: true } as never)
113− : ({} as never),
114− ),
115− ready: Effect.void,
116− probe: Effect.void,
117− closed: Effect.never,
118− };
119−}
120−
121−function awaitThreadState(
122− observed: Queue.Queue<EnvironmentThreadState>,
123− predicate: (state: EnvironmentThreadState) => boolean,
124−) {
125− return Queue.take(observed).pipe(
126− Effect.repeat({
127− until: predicate,
128− }),
129− );
130−}
131−
132−const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (options?: {
133− readonly cached?: OrchestrationThread;
134− readonly httpSnapshot?: Option.Option<OrchestrationThreadDetailSnapshot>;
135− readonly completionMarker?: boolean;
136−}) {
137− const inputs = yield* Queue.unbounded<TestThreadInput>();
138− const observed = yield* Queue.unbounded<EnvironmentThreadState>();
139− const latest = yield* Ref.make<EnvironmentThreadState>(EMPTY_ENVIRONMENT_THREAD_STATE);
140− const retryCount = yield* Ref.make(0);
141− const subscriptionCount = yield* Ref.make(0);
142− const loaderCalls = yield* Ref.make(0);
143− const lastSubscribeAfterSequence = yield* Ref.make<number | undefined>(undefined);
144− const lastRequestCompletionMarker = yield* Ref.make<boolean | undefined>(undefined);
145− const savedThreads = yield* Ref.make<ReadonlyArray<OrchestrationThreadDetailSnapshot>>([]);
146− const removedThreads = yield* Ref.make<ReadonlyArray<ThreadId>>([]);
147− const wakeups = yield* Queue.unbounded<ConnectionWakeups.ConnectionWakeup>();
148− const supervisorState = yield* SubscriptionRef.make<SupervisorConnectionState>(
149− AVAILABLE_CONNECTION_STATE,
150− );
151− const streamFrom = (queue: Queue.Queue<TestThreadInput>) =>
152− Stream.fromQueue(queue).pipe(
153− Stream.mapEffect((input) =>
154− input instanceof Error ? Effect.fail(input) : Effect.succeed(input),
155− ),
156− );
157− const client = {
158− [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: {
159− readonly afterSequence?: number;
160− readonly requestCompletionMarker?: boolean;
161− }) =>
162− Stream.unwrap(
163− Ref.updateAndGet(subscriptionCount, (count) => count + 1).pipe(
164− Effect.andThen(Ref.set(lastSubscribeAfterSequence, input.afterSequence)),
165− Effect.andThen(Ref.set(lastRequestCompletionMarker, input.requestCompletionMarker)),
166− Effect.as(streamFrom(inputs)),
167− ),
168− ),
169− } as unknown as WsRpcProtocolClient;
170− const supervisorSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>(
171− Option.some(
172− testSession(
173− client,
174− options?.completionMarker === true ? { completionMarker: true } : undefined,
175− ),
176− ),
177− );
178− const prepared = yield* SubscriptionRef.make<Option.Option<PreparedConnection>>(
179− Option.some(PREPARED),
180− );
181− const snapshotLoader = ThreadSnapshotLoader.of({
182− load: (_prepared, threadId) =>
183− Ref.update(loaderCalls, (count) => count + 1).pipe(
184− Effect.as(
185− threadId === THREAD_ID
186− ? (options?.httpSnapshot ?? Option.none<OrchestrationThreadDetailSnapshot>())
187− : Option.none<OrchestrationThreadDetailSnapshot>(),
188− ),
189− ),
190− });
191− const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
192− target: TARGET,
193− state: supervisorState,
194− session: supervisorSession,
195− prepared,
196− connect: Effect.void,
197− disconnect: Effect.void,
198− retryNow: Ref.update(retryCount, (count) => count + 1),
199− } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
200− const cache = Persistence.EnvironmentCacheStore.of({
201− loadShell: () => Effect.succeed(Option.none()),
202− saveShell: () => Effect.void,
203− loadThread: (_environmentId, threadId) =>
204− Effect.succeed(
205− threadId === THREAD_ID && options?.cached !== undefined
206− ? Option.some({
207− snapshotSequence: CACHED_SNAPSHOT_SEQUENCE,
208− thread: options.cached,
209− })
210− : Option.none(),
211− ),
212− saveThread: (_environmentId, thread) =>
213− Ref.update(savedThreads, (current) => [...current, thread]),
214− removeThread: (_environmentId, threadId) =>
215− Ref.update(removedThreads, (current) => [...current, threadId]),
216− loadServerConfig: () => Effect.succeed(Option.none()),
217− saveServerConfig: () => Effect.void,
218− loadVcsRefs: () => Effect.succeed(Option.none()),
219− saveVcsRefs: () => Effect.void,
220− removeVcsRefs: () => Effect.void,
221− clearVcsRefs: () => Effect.void,
222− clear: () => Effect.void,
223− });
224− const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe(
225− Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
226− Effect.provideService(Persistence.EnvironmentCacheStore, cache),
227− Effect.provideService(ThreadSnapshotLoader, snapshotLoader),
228− Effect.provideService(
229− ConnectionWakeups.ConnectionWakeups,
230− ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.fromQueue(wakeups) }),
231− ),
232− );
233− yield* SubscriptionRef.changes(threadState).pipe(
234− Stream.runForEach((state) =>
235− Ref.set(latest, state).pipe(Effect.andThen(Queue.offer(observed, state))),
236− ),
237− Effect.forkScoped,
238− );
239−
240− return {
241− inputs,
242− observed,
243− latest,
244− retryCount,
245− subscriptionCount,
246− loaderCalls,
247− lastSubscribeAfterSequence,
248− lastRequestCompletionMarker,
249− supervisorState,
250− supervisorSession,
251− savedThreads,
252− removedThreads,
253− wakeups,
254− replaceSession: SubscriptionRef.set(
255− supervisorSession,
256− Option.some(
257− testSession(
258− client,
259− options?.completionMarker === true ? { completionMarker: true } : undefined,
260− ),
261− ),
262− ),
263− };
264−});
265−
266−const snapshot = (thread: OrchestrationThread): OrchestrationThreadStreamItem => ({
267− kind: "snapshot",
268− snapshot: {
269− snapshotSequence: 1,
270− thread,
271− },
272−});
273−
274−const synchronized = (): OrchestrationThreadStreamItem => ({ kind: "synchronized" });
275−
276−const titleUpdated = (title: string, sequence = 2): OrchestrationThreadStreamItem => ({
277− kind: "event",
278− event: {
279− eventId: EventId.make("event-title"),
280− sequence,
281− occurredAt: "2026-04-01T01:00:00.000Z",
282− commandId: null,
283− causationEventId: null,
284− correlationId: null,
285− metadata: {},
286− aggregateKind: "thread",
287− aggregateId: THREAD_ID,
288− type: "thread.meta-updated",
289− payload: {
290− threadId: THREAD_ID,
291− title,
292− updatedAt: "2026-04-01T01:00:00.000Z",
293− },
294− },
295−});
296−
297−const deleted = (): OrchestrationThreadStreamItem => ({
298− kind: "event",
299− event: {
300− eventId: EventId.make("event-deleted"),
301− sequence: 3,
302− occurredAt: "2026-04-01T02:00:00.000Z",
303− commandId: null,
304− causationEventId: null,
305− correlationId: null,
306− metadata: {},
307− aggregateKind: "thread",
308− aggregateId: THREAD_ID,
309− type: "thread.deleted",
310− payload: {
311− threadId: THREAD_ID,
312− deletedAt: "2026-04-01T02:00:00.000Z",
313− },
314− },
315−});
316−
317−describe("EnvironmentThreads", () => {
318− it.effect("publishes cached data immediately from a warm cache", () =>
319− Effect.gen(function* () {
320− const harness = yield* makeHarness({ cached: BASE_THREAD });
321− const state = yield* awaitThreadState(harness.observed, (value) => Option.isSome(value.data));
322−
323− expect(Option.getOrThrow(state.data)).toEqual(BASE_THREAD);
324− expect(Option.isNone(state.error)).toBe(true);
325− }),
326− );
327−
328− it.effect("resumes a warm cache via afterSequence without an HTTP fetch", () =>
329− Effect.gen(function* () {
330− const harness = yield* makeHarness({ cached: BASE_THREAD });
331−
332− // The warm cache reaches live from the cached data, and a live event
333− // applies on top of it.
334− yield* Queue.offer(harness.inputs, titleUpdated("Live title", CACHED_SNAPSHOT_SEQUENCE + 1));
335− yield* awaitThreadState(
336− harness.observed,
337− (value) =>
338− value.status === "live" &&
339− Option.isSome(value.data) &&
340− value.data.value.title === "Live title",
341− );
34234
343− // The subscription resumed from the cached sequence and never fetched the
344− // full snapshot over HTTP.
345− expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(CACHED_SNAPSHOT_SEQUENCE);
346− expect(yield* Ref.get(harness.loaderCalls)).toBe(0);
347− }),
348− );
349−
350− it.effect("reduces live events and persists the latest thread", () =>
351− Effect.gen(function* () {
352− const harness = yield* makeHarness({ cached: BASE_THREAD });
353− yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD));
354− yield* Queue.offer(harness.inputs, titleUpdated("Live title"));
355−
356− const state = yield* awaitThreadState(
357− harness.observed,
358− (value) =>
359− value.status === "live" &&
360− Option.isSome(value.data) &&
361− value.data.value.title === "Live title",
362− );
363− yield* TestClock.adjust("500 millis");
364− yield* Effect.yieldNow;
365−
366− expect(Option.getOrThrow(state.data).title).toBe("Live title");
367− expect((yield* Ref.get(harness.savedThreads)).at(-1)?.thread.title).toBe("Live title");
368− expect((yield* Ref.get(harness.savedThreads)).at(-1)?.snapshotSequence).toBe(2);
369− }),
370− );
371−
372− it.effect("does not persist active thread snapshots during streaming or teardown", () =>
373− Effect.gen(function* () {
374− const savedThreads = yield* Effect.scoped(
375− Effect.gen(function* () {
376− const harness = yield* makeHarness({ cached: ACTIVE_THREAD });
377− yield* awaitThreadState(
378− harness.observed,
379− (value) =>
380− value.status === "live" &&
381− Option.isSome(value.data) &&
382− value.data.value.session?.status === "running",
383− );
384−
385− yield* TestClock.adjust("500 millis");
386− yield* Effect.yieldNow;
387−
388− expect(yield* Ref.get(harness.savedThreads)).toEqual([]);
389− return harness.savedThreads;
390− }),
391− );
392−
393− expect(yield* Ref.get(savedThreads)).toEqual([]);
394− }),
395− );
396−
397− it.effect("seeds the thread from the HTTP snapshot and resumes live events", () =>
35+describe("V2 thread synchronization", () => {
36+ it.effect("applies a snapshot followed by committed projection events", () =>
39837 Effect.gen(function* () {
399− const httpThread: OrchestrationThread = { ...BASE_THREAD, title: "HTTP title" };
400− const harness = yield* makeHarness({
401− httpSnapshot: Option.some({ snapshotSequence: 1, thread: httpThread }),
38+ const events = yield* Queue.unbounded<OrchestrationV2ThreadStreamItem>();
39+ const client = {
40+ [ORCHESTRATION_V2_WS_METHODS.subscribeThread]: () => Stream.fromQueue(events),
41+ } as unknown as WsRpcProtocolClient;
42+ const rpcSession: RpcSession.RpcSession = {
43+ client,
44+ initialConfig: Effect.never,
45+ ready: Effect.void,
46+ probe: Effect.void,
47+ closed: Effect.never,
48+ };
49+ const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
50+ target: TARGET,
51+ state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE),
52+ session: yield* SubscriptionRef.make(Option.some(rpcSession)),
53+ prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),
54+ connect: Effect.void,
55+ disconnect: Effect.void,
56+ retryNow: Effect.void,
40257 });
403− // No socket snapshot is pushed; only a live event arrives over the socket.
404− // It can only be applied if the HTTP snapshot already seeded the thread.
405− yield* Queue.offer(harness.inputs, titleUpdated("Live title", 2));
406−
407− const state = yield* awaitThreadState(
408− harness.observed,
409− (value) =>
410− value.status === "live" &&
411− Option.isSome(value.data) &&
412− value.data.value.title === "Live title",
413− );
414−
415− expect(Option.getOrThrow(state.data).title).toBe("Live title");
416− // Cold cache: the full snapshot was loaded over HTTP and the socket
417− // resumed from that snapshot's sequence.
418− expect(yield* Ref.get(harness.loaderCalls)).toBeGreaterThanOrEqual(1);
419− expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(1);
420− }),
421− );
422−
423− it.effect("ignores replayed thread events at or below the snapshot sequence", () =>
424− Effect.gen(function* () {
425− const harness = yield* makeHarness({ cached: BASE_THREAD });
426− yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD));
427− yield* Queue.offer(harness.inputs, titleUpdated("Replayed title", 1));
428− yield* Queue.offer(harness.inputs, titleUpdated("Live title", 2));
429−
430− const state = yield* awaitThreadState(
431− harness.observed,
432− (value) =>
433− value.status === "live" &&
434− Option.isSome(value.data) &&
435− value.data.value.title === "Live title",
436− );
437−
438− expect(Option.getOrThrow(state.data).title).toBe("Live title");
439− }),
440− );
441−
442− it.effect("removes cached data when the thread is deleted", () =>
443− Effect.gen(function* () {
444− const harness = yield* makeHarness({ cached: BASE_THREAD });
445− yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD));
446− yield* Queue.offer(harness.inputs, deleted());
447−
448− const state = yield* awaitThreadState(
449− harness.observed,
450− (value) => value.status === "deleted",
451− );
452−
453− expect(Option.isNone(state.data)).toBe(true);
454− expect(yield* Ref.get(harness.removedThreads)).toEqual([THREAD_ID]);
455− }),
456− );
457−
458− it.effect("does not resurrect a deleted thread when the app returns to the foreground", () =>
459− Effect.gen(function* () {
460− const harness = yield* makeHarness({
461− cached: BASE_THREAD,
462− completionMarker: true,
463− httpSnapshot: Option.some({
464− snapshotSequence: 4,
465− thread: { ...BASE_THREAD, title: "Stale HTTP thread" },
466− }),
58+ const cache = Persistence.EnvironmentCacheStore.of({
59+ loadShell: () => Effect.succeed(Option.none()),
60+ saveShell: () => Effect.void,
61+ loadThread: () => Effect.succeed(Option.none()),
62+ saveThread: () => Effect.void,
63+ removeThread: () => Effect.void,
64+ clear: () => Effect.void,
46765 });
468− yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD));
469− yield* Queue.offer(harness.inputs, deleted());
470− yield* awaitThreadState(harness.observed, (value) => value.status === "deleted");
471−
472− expect(yield* Ref.get(harness.loaderCalls)).toBe(0);
473− yield* Queue.offer(harness.wakeups, "application-active");
474− for (let attempt = 0; attempt < 100; attempt += 1) {
475− if ((yield* Ref.get(harness.subscriptionCount)) >= 2) break;
476− yield* Effect.yieldNow;
477− }
478−
479− const latest = yield* Ref.get(harness.latest);
480− expect(yield* Ref.get(harness.subscriptionCount)).toBe(2);
481− expect(yield* Ref.get(harness.loaderCalls)).toBe(0);
482− expect(latest.status).toBe("deleted");
483− expect(Option.isNone(latest.data)).toBe(true);
484− }),
485− );
486−
487− it.effect("preserves data after a domain failure and resumes on a replacement session", () =>
488− Effect.gen(function* () {
489− const harness = yield* makeHarness({ cached: BASE_THREAD });
490− yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD));
491− yield* Queue.offer(harness.inputs, new Error("stream failed"));
492−
493− const state = yield* awaitThreadState(harness.observed, (value) =>
494− Option.isSome(value.error),
495− );
496−
497− expect(Option.getOrThrow(state.data)).toEqual(BASE_THREAD);
498− expect(Option.getOrThrow(state.error)).toBe("stream failed");
499− expect(yield* Ref.get(harness.retryCount)).toBe(0);
500−
501− yield* harness.replaceSession;
502− for (let attempt = 0; attempt < 100; attempt += 1) {
503− if ((yield* Ref.get(harness.subscriptionCount)) >= 2) {
504− break;
505− }
506− yield* Effect.yieldNow;
507− }
508− yield* Queue.offer(
509− harness.inputs,
510− snapshot({
511− ...BASE_THREAD,
512− title: "Recovered thread",
513− }),
514− );
515− const recovered = yield* awaitThreadState(
516− harness.observed,
517− (value) =>
518− value.status === "live" &&
519− Option.isSome(value.data) &&
520− value.data.value.title === "Recovered thread",
521− );
522−
523− expect(Option.isNone(recovered.error)).toBe(true);
524− expect(yield* Ref.get(harness.subscriptionCount)).toBe(2);
525− }),
526− );
527−
528− it.effect("recovers from a transient domain failure without replacing the session", () =>
529− Effect.gen(function* () {
530− const harness = yield* makeHarness();
531− yield* Queue.offer(harness.inputs, new Error("thread not found yet"));
532−
533− const failed = yield* awaitThreadState(harness.observed, (value) =>
534− Option.isSome(value.error),
535− );
536− expect(Option.getOrThrow(failed.error)).toBe("thread not found yet");
537− expect(yield* Ref.get(harness.subscriptionCount)).toBe(1);
538−
539− yield* TestClock.adjust("250 millis");
540− for (let attempt = 0; attempt < 100; attempt += 1) {
541− if ((yield* Ref.get(harness.subscriptionCount)) >= 2) {
542− break;
543− }
544− yield* Effect.yieldNow;
545− }
546− yield* Queue.offer(
547− harness.inputs,
548− snapshot({
549− ...BASE_THREAD,
550− title: "Materialized thread",
551− }),
552− );
553−
554− const recovered = yield* awaitThreadState(
555− harness.observed,
556− (value) =>
557− value.status === "live" &&
558− Option.isSome(value.data) &&
559− value.data.value.title === "Materialized thread",
66+ const state = yield* makeEnvironmentThreadState(v2ThreadId).pipe(
67+ Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
68+ Effect.provideService(Persistence.EnvironmentCacheStore, cache),
56069 );
56170
562− expect(Option.isNone(recovered.error)).toBe(true);
563− expect(yield* Ref.get(harness.subscriptionCount)).toBe(2);
564− expect(yield* Ref.get(harness.retryCount)).toBe(0);
565− }),
566− );
567−
568− it.effect("does not overwrite a live snapshot when the supervisor becomes ready", () =>
569− Effect.gen(function* () {
570− const harness = yield* makeHarness({ cached: BASE_THREAD });
571− yield* SubscriptionRef.set(harness.supervisorState, {
572− desired: true,
573− network: "online",
574− phase: "connecting",
575− stage: "synchronizing",
576− attempt: 1,
577− generation: 0,
578− lastFailure: null,
579− retryAt: null,
71+ yield* Queue.offer(events, {
72+ kind: "snapshot",
73+ snapshotSequence: 1,
74+ projection: v2Projection,
58075 });
581− yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD));
582− yield* awaitThreadState(harness.observed, (value) => value.status === "live");
583−
584− yield* SubscriptionRef.set(harness.supervisorState, {
585− desired: true,
586− network: "online",
587− phase: "connected",
588− stage: null,
589− attempt: 1,
590− generation: 1,
591− lastFailure: null,
592− retryAt: null,
76+ yield* SubscriptionRef.changes(state).pipe(
77+ Stream.filter((current) => current.status === "live"),
78+ Stream.runHead,
79+ );
80+
81+ const occurredAt = DateTime.makeUnsafe("2026-06-20T02:00:00.000Z");
82+ yield* Queue.offer(events, {
83+ kind: "event",
84+ sequence: 2,
85+ event: {
86+ id: EventId.make("event-title"),
87+ type: "thread.metadata-updated",
88+ threadId: v2ThreadId,
89+ occurredAt,
90+ payload: { ...v2Projection.thread, title: "Updated", updatedAt: occurredAt },
91+ },
59392 });
594− for (let index = 0; index < 10; index += 1) {
595− yield* Effect.yieldNow;
596− }
93+ yield* SubscriptionRef.changes(state).pipe(
94+ Stream.filter((current) => Option.getOrNull(current.data)?.thread.title === "Updated"),
95+ Stream.runHead,
96+ );
59797
598− expect((yield* Ref.get(harness.latest)).status).toBe("live");
98+ expect(Option.getOrThrow((yield* SubscriptionRef.get(state)).data).thread.title).toBe(
99+ "Updated",
100+ );
599101 }),
600102 );
601103
packages/client-runtime/src/state/threads.ts+45−88
@@ -1,9 +1,8 @@
11 import {
2− ORCHESTRATION_WS_METHODS,
2+ ORCHESTRATION_V2_WS_METHODS,
33 type EnvironmentId as EnvironmentIdType,
4− type OrchestrationThread,
5− type OrchestrationThreadDetailSnapshot,
6− type OrchestrationThreadStreamItem,
4+ type OrchestrationV2ThreadProjection,
5+ type OrchestrationV2ThreadStreamItem,
76 type ThreadId as ThreadIdType,
87 } from "@t3tools/contracts";
98 import * as Cause from "effect/Cause";
@@ -23,16 +22,27 @@ import { EnvironmentCacheStore } from "../platform/persistence.ts"; · 13 unmodified lines
2322 import { subscribeDynamic } from "../rpc/client.ts";
2423 import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts";
2524 import { parseThreadKey, threadKey } from "./entities.ts";
26−import { applyThreadDetailEvent } from "./threadReducer.ts";
25+import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection.ts";
2726 import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
2827 import { followStreamInEnvironment } from "./runtime.ts";
29−import {
30− EMPTY_ENVIRONMENT_THREAD_STATE,
31− type EnvironmentThreadState,
32− type EnvironmentThreadStatus,
33−} from "./threadState.ts";
3428
35−function statusWithoutLiveData(data: Option.Option<OrchestrationThread>): EnvironmentThreadStatus {
29+export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted";
30+
31+export interface EnvironmentThreadState {
32+ readonly data: Option.Option<OrchestrationV2ThreadProjection>;
33+ readonly status: EnvironmentThreadStatus;
34+ readonly error: Option.Option<string>;
35+}
36+
37+export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = {
38+ data: Option.none(),
39+ status: "empty",
40+ error: Option.none(),
41+};
42+
43+function statusWithoutLiveData(
44+ data: Option.Option<OrchestrationV2ThreadProjection>,
45+): EnvironmentThreadStatus {
3646 return Option.isSome(data) ? "cached" : "empty";
3747 }
3848
@@ -64,7 +74,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 25 unmodified lines
6474 threadId,
6575 error: error.message,
6676 }),
67− Effect.as(Option.none<OrchestrationThreadDetailSnapshot>()),
77+ Effect.as(Option.none<OrchestrationV2ThreadProjection>()),
6878 ),
6979 ),
7080 );
@@ -74,16 +84,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 3 unmodified lines
7484 status: statusWithoutLiveData(cachedThread),
7585 error: Option.none(),
7686 });
77− // Seed the resume cursor from the cached snapshot so a warm cache can catch up
78− // via `afterSequence` instead of re-downloading the full thread body.
79− const lastSequence = yield* SubscriptionRef.make(
80− Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }),
81− );

147 more lines aren’t shown here.

packages/contracts/src/chatAttachment.ts+21−1

This diff is large: its 32 lines aren’t shown here.

packages/contracts/src/orchestrationV2.ts+22−2

This diff is large: its 70 lines aren’t shown here.

packages/contracts/src/project.ts+11−0

This diff is large: its 29 lines aren’t shown here.

packages/contracts/src/rpc.ts+22−0

This diff is large: its 74 lines aren’t shown here.

packages/shared/src/orchestrationTiming.ts+15−15

This diff is large: its 53 lines aren’t shown here.