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
20- Shape 3 performs the backend hard cut and intentionally breaks the old client protocol.20- Shape 3 performs the backend hard cut and intentionally breaks the old client protocol.
21- Shape 4.0 updates web and mobile to the final protocol while preserving the current product experience and component structure.21- Shape 4.0 updates web and mobile to the final protocol while preserving the current product experience and component structure.
22- Shape 4.5 enriches that working application with V2-native graph, execution, tool, and context information.22- 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.
24- 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.25- 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.
25- Do not drop V1 tables, migrations, provider runtime rows, or attachment files during this plan.26- Do not drop V1 tables, migrations, provider runtime rows, or attachment files during this plan.
26- V1 removal happens inside the shape that replaces it; there is no deferred general cleanup phase.27- V1 removal happens inside the shape that replaces it; there is no deferred general cleanup phase.
27 28
28## Alignment With The Current Production Path29## Alignment With The Current Production Path
29 30
30At 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.31At 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.
31 32
32The historical pre-cut flow was:33The historical pre-cut flow was:
33 34
@@ -40,21 +41,22 @@ Web/mobile model and mode selection · 6 unmodified lines
40 -> driver-specific legacy adapter41 -> driver-specific legacy adapter
41```42```
42 43
43The current branch boundary is:44The current post-Shape-4.0 boundary is:
44 45
45```text46```text
46ordinary web/mobile state and commands47ordinary web/mobile state and commands
47 -> orchestrationV1.* RPCs48 -> shared client-runtime V2 shell and thread projections
48 -> no server handler after the Shape 3 hard cut
49
50V2 debug client, CLI, MCP, and backend consumers
51 -> final orchestration.* RPCs / V2 application services49 -> final orchestration.* RPCs / V2 application services
52 -> shared application event transaction50 -> shared application event transaction
53 -> Agent Orchestrator V251 -> Agent Orchestrator V2
54 -> provider execution52 -> provider execution
53
54V2 debug client
55 -> direct V2 projection and item inspection
56 -> reference presentation for Shape 4.5 production enrichment
55```57```
56 58
57Shape 4.0 closes this intentional client/server gap. Shape 4.5 enriches the resulting working frontend.59Shape 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.
58 60
59Current ownership and intended treatment:61Current ownership and intended treatment:
60 62
@@ -315,7 +317,7 @@ Add coverage for: · 254 unmodified lines
315 317
316## Shape 2: Application Services Around V2318## Shape 2: Application Services Around V2
317 319
318Implementation status: complete. Shape 3 completed the backend protocol cut; the production web/mobile cutover remains intentionally deferred to Shape 4.0.320Implementation status: complete. Shape 3 completed the backend protocol cut; the production web/mobile cutover was subsequently completed in Shape 4.0.
319 321
320### Goal322### Goal
321 323
@@ -702,7 +704,16 @@ Add backend integration coverage for: · 380 unmodified lines
702 704
703## Shape 4.0: V2 Frontend Cutover And Product Parity705## Shape 4.0: V2 Frontend Cutover And Product Parity
704 706
705Implementation status: pending.707Implementation status: complete.
708
709Implementation 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.
706 717
707### Goal718### Goal
708 719
@@ -716,6 +727,8 @@ At the end of Shape 4.0, the ordinary application—not only the V2 debug route · 7 unmodified lines
716- Reuse existing sidebar, chat, composer, approval, archive, diff, settings, and model-picker components where their behavior remains correct.727- Reuse existing sidebar, chat, composer, approval, archive, diff, settings, and model-picker components where their behavior remains correct.
717- Generalize component props around V2-native view models instead of manufacturing V1 commands, events, activities, or thread projections.728- Generalize component props around V2-native view models instead of manufacturing V1 commands, events, activities, or thread projections.
718- Retain the complete V2 projection in client state even when the parity UI initially presents only a subset of it.729- 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.
719- 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.732- 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.
720- Defer graph visualization, rich execution inspection, and provider-native tool presentation to Shape 4.5.733- Defer graph visualization, rich execution inspection, and provider-native tool presentation to Shape 4.5.
721- Do not import V1 server rows or migrate existing V1 threads in this shape.734- 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
735 748
736Bump 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.749Bump 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.
737 750
738### 2. Add a parity-focused V2 presentation adapter751### 2. Add a temporary parity-focused V2 presentation adapter
739 752
740Create shared, V2-native client view models for the existing application surfaces, including:753Create shared, V2-native client view models for the existing application surfaces, including:
741 754
@@ -750,7 +763,7 @@ Create shared, V2-native client view models for the existing application surface · 8 unmodified lines
750 763
751Derive 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.764Derive 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.
752 765
753Web and mobile share the projection reducer and parity derivation rules. Platform-specific components may render the resulting view models differently.766Web 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.
754 767
755### 3. Replace production client commands768### 3. Replace production client commands
756 769
@@ -847,6 +860,9 @@ Add web and mobile integration coverage for: · 90 unmodified lines
847- A user can create a project and thread, converse with an agent, reconnect, respond to requests, inspect diffs, archive, and delete.860- A user can create a project and thread, converse with an agent, reconnect, respond to requests, inspect diffs, archive, and delete.
848- Existing V1-era product behavior that remains supported by V2 is present without routing through V1 data shapes.861- Existing V1-era product behavior that remains supported by V2 is present without routing through V1 data shapes.
849- Full V2 projections and structured turn-item payloads are retained even when rendered generically.862- 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.
850- No production code calls an `orchestrationV1.*` endpoint.866- No production code calls an `orchestrationV1.*` endpoint.
851- No V2-to-V1 server or persistence compatibility projection exists.867- No V2-to-V1 server or persistence compatibility projection exists.
852- The V2 debug route is no longer the only functional V2 client.868- 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
855 871
856## Shape 4.5: V2-Native Frontend Enrichment872## Shape 4.5: V2-Native Frontend Enrichment
857 873
858Implementation status: pending.874Implementation 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.
859 875
860### Goal876### Goal
861 877
862Enrich 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.878Enrich 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.
863 879
864### 1. Build the complete shared V2 presentation model880The 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.
865 881
866Extend the shared derivation layer to use:882### Shape 4.5 implementation order
867 883
868- `visibleTurnItems`884Complete 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.
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
878Expose 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
892Preserve 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
896Create a shared graph/lineage model from thread lineage, execution nodes, subagents, forks, and context transfers. It must support:
897 885
898- root, parent, child, fork, and subagent relationships886### 1. Establish the scoped projection and item atom boundary
899- navigation between related threads887
900- per-node run and terminal status888Keep shell and detail as independent sources:
901- merge-back and context-transfer state889
902- archived or deleted related threads890- `EnvironmentThreadShell` remains the environment-scoped, list-oriented view of the server shell stream.
903- missing-parent, partial-replay, and cycle-safe fallback behavior891- 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
896The 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
905Do 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
912These 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
914Keep 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
918Fix the incremental client reducer before the production rich timeline depends on it:
904 919
905Web 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
906 925
907### 3. Enrich tool and work-log presentation926This is a reducer allocation and correctness requirement, not an atom-level equality workaround.
908 927
909Reuse the existing chat activity and work-log components, then add V2-aware renderers for:928### 2. Move the web conversation to authoritative V2 items
910 929
911- command execution with command, working directory, lifecycle, output, duration, and exit state930Implement 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
939Reuse 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
941Web 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
945After 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
947Mobile 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
949Promote 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
953Add item renderers and targeted inspectors for:
954
955- command execution with input, working directory where available, lifecycle, output, duration, and exit state
912- file changes with affected paths and patch/diff navigation956- file changes with affected paths and patch/diff navigation
913- file, code, and web search with query and result provenance957- file, code, and web search with query and result provenance
914- MCP and provider-native tool calls958- MCP, dynamic, and provider-native tool calls with complete structured fallback
915- dynamic tools with complete structured input/output fallback
916- nested tool work associated with execution nodes or subagents959- nested tool work associated with execution nodes or subagents
917- failed, interrupted, retried, and superseded calls960- 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
918 966
919Rendering must preserve live updates without duplicating rows as attempts or provider items change. Large output should remain inspectable without overwhelming the conversation timeline.967The 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.
920 968
921### 4. Expose runs, attempts, and recovery969Keep 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.
922 970
923Add UI affordances for:971### 5. Expose relationships and V2-native workflows
924 972
925- queued, steering, restarting, and active runs973Build environment-scoped, cycle-safe graph/lineage derivation from shell threads, thread lineage, execution nodes, subagents, forks, and context transfers. It must support:
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
932 974
933Keep 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
934 981
935### 5. Add V2-native workflows982Web 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.
936 983
937Integrate:984Integrate capability-gated workflows for:
938 985
939- thread fork and merge-back986- thread fork and merge-back
940- provider switching and provider-session detach987- provider switching and provider-session detach
941- advanced queue reorder and promotion988- advanced queue reorder and promotion
942- subagent navigation and delegated-task state989- subagent navigation and delegated-task state
943- plan/todo progress990- plan/todo progress
944- checkpoint scopes, diff, and rollback991- checkpoint scope, diff, and rollback
945- context handoff and transfer visibility992- context handoff and transfer visibility
946 993
947Each action must be capability-gated from V2 projection/runtime policy rather than provider-name checks in the UI.994Actions derive from V2 capability and runtime policy. They must not use provider-name conditionals.
995
996### 6. Retire parity state and finish cleanup
997
998After web and mobile no longer depend on the Shape 4.0 full-thread parity facade:
948 999
949### 6. Finish web/mobile reuse and cleanup1000- 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
950 1006
951- Use one shared presentation derivation for web and mobile.1007No 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.
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.
958 1008
959### Shape 4.5 tests1009### Shape 4.5 tests
960 1010
961Add shared presentation tests for:1011Add client-runtime state and reducer coverage for:
962 1012
963- every known V2 turn-item type and the dynamic fallback1013- environment-scoped detail identity without copying or mutating the server projection
964- graph construction for roots, forks, subagents, merges, archives, and missing nodes1014- cached, synchronizing, live, deleted, and error state independent of projection content
965- incremental updates without duplicate graph nodes or timeline rows1015- preservation of local, inherited, and synthetic `visibleTurnItems`
966- run attempts, retry, recovery, and interruption presentation1016- unchanged `visibleTurnItems` identity for run/attempt updates that do not change visibility
967- provider-session transition and context-handoff presentation1017- single-row replacement and unaffected-row identity for streaming item updates
968- plans, todos, checkpoints, and rollback1018- 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
969- capability-gated action availability1021- capability-gated action availability
970 1022
971Add web and mobile integration coverage for:1023Add web and mobile integration coverage for:
972 1024
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
973- related-thread navigation1030- related-thread navigation
974- rich tool-call expansion and live updates
975- fork and merge-back1031- fork and merge-back
976- subagent and delegated-task inspection1032- subagent and delegated-task inspection
977- queue reorder and promotion1033- queue reorder and promotion
978- provider switch and detach1034- provider switch and detach
979- context handoff1035- context handoff
980- reconnect while graph, tool, or attempt state is changing1036- reconnect while item, graph, tool, request, or attempt state is changing
1037- all existing Shape 4.0 product flows
981 1038
982### Shape 4.5 exit gate1039### Shape 4.5 exit gate
983 1040
984- Web and mobile remain fully usable for every Shape 4.0 flow.1041- Web and mobile remain fully usable for every Shape 4.0 flow.
985- Thread lineage, forks, subagents, and merge relationships are navigable.1042- Production web and mobile conversations render from server-authoritative `visibleTurnItems`; neither reconstructs the chat by merging separate message, plan, and work-entry collections.
986- Typed tool calls and their structured results are visible without losing provider-native fallback data.1043- Thread detail values carry explicit environment scope while retaining the pristine V2 projection and nested entity identities.
987- Runs, attempts, retries, recovery, plans, checkpoints, and handoffs have coherent presentation.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.
988- New V2 capabilities are capability-gated and do not rely on provider-name conditionals.1052- 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.
990- No production TypeScript import references a V1 client orchestration type or endpoint.1055- 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.
991- `vp check`, `vp run typecheck`, and `vp test` pass.1057- `vp check`, `vp run typecheck`, and `vp test` pass.
992- `vp run lint:mobile` passes for mobile changes.1058- `vp run lint:mobile` passes for mobile changes.
993 1059
@@ -1011,7 +1077,8 @@ Nothing in Shape 4.0 or 4.5 may destroy, rewrite, or make assumptions that close · 17 unmodified lines
1011At the end of Shape 4.5:1077At the end of Shape 4.5:
1012 1078
1013- V2 is the only live agent orchestration system from provider process through web and mobile UI.1079- 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.
1015- V1 runtime, server endpoints, production client state, and production client operations are removed.1082- V1 runtime, server endpoints, production client state, and production client operations are removed.
1016- Reusable application event-sourcing and platform infrastructure remain.1083- Reusable application event-sourcing and platform infrastructure remain.
1017- Legacy database rows, provider continuation data, attachments, and other migration inputs remain untouched and read-only.1084- 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
2 ConnectionPersistenceError,2 ConnectionPersistenceError,
3 ConnectionRegistrationStore,3 ConnectionRegistrationStore,
4 ConnectionTargetStore,4 ConnectionTargetStore,
5 EnvironmentCacheStore,
6 ORCHESTRATION_CACHE_SCHEMA_VERSION,
7 StoredOrchestrationShellSnapshot,
8 StoredOrchestrationThreadSnapshot,
9 decodeOrDiscardOrchestrationCache,
5 registerConnectionInCatalog,10 registerConnectionInCatalog,
6 removeConnectionFromCatalog,11 removeConnectionFromCatalog,
7 removeCatalogValue,12 removeCatalogValue,
@@ -13,11 +18,88 @@ import { · 5 unmodified lines
13 CredentialStore,18 CredentialStore,
14 ProfileStore,19 ProfileStore,
15} from "@t3tools/client-runtime/connection";20} from "@t3tools/client-runtime/connection";
21import { EnvironmentId, ThreadId } from "@t3tools/contracts";
16import * as Context from "effect/Context";22import * as Context from "effect/Context";
17import * as Effect from "effect/Effect";23import * as Effect from "effect/Effect";
18import * as Layer from "effect/Layer";24import * as Layer from "effect/Layer";
19import * as Option from "effect/Option";25import * as Option from "effect/Option";
20import * as CatalogStore from "./catalog-store";26import * as Schema from "effect/Schema";
27import * as SecureStore from "expo-secure-store";
28
29import { makeCatalogStore, type SecureCatalogStorage } from "./catalog-store";
30
31const SHELL_SNAPSHOT_CACHE_DIRECTORY = "connection-shell-snapshots";
32const LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots";
33const THREAD_SNAPSHOT_CACHE_DIRECTORY = "connection-thread-snapshots";
34
35const StoredShellSnapshot = StoredOrchestrationShellSnapshot;
36const StoredThreadSnapshot = StoredOrchestrationThreadSnapshot;
37const decodeStoredShellSnapshot = Schema.decodeUnknownResult(StoredShellSnapshot);
38const encodeStoredShellSnapshot = Schema.encodeUnknownResult(StoredShellSnapshot);
39const decodeStoredThreadSnapshot = Schema.decodeUnknownResult(StoredThreadSnapshot);
40const encodeStoredThreadSnapshot = Schema.encodeUnknownResult(StoredThreadSnapshot);
41
42function 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
49function 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
65function threadSnapshotFileName(threadId: ThreadId): string {
66 return `${encodeURIComponent(threadId)}.json`;
67}
68
69const 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
92const 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});
21 103
22function targetPersistenceError(104function targetPersistenceError(
23 operation: "list-targets" | "register-connection" | "remove-connection",105 operation: "list-targets" | "register-connection" | "remove-connection",
@@ -128,6 +210,172 @@ export const connectionStorageLayer = Layer.effectContext( · 104 unmodified lines
128 ),210 ),
129 })),211 })),
130 });212 });
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
131 return Context.make(ConnectionTargetStore, targetStore).pipe(379 return Context.make(ConnectionTargetStore, targetStore).pipe(
132 Context.add(ConnectionRegistrationStore, registrationStore),380 Context.add(ConnectionRegistrationStore, registrationStore),
133 Context.add(ProfileStore.ConnectionProfileStore, profileStore),381 Context.add(ProfileStore.ConnectionProfileStore, profileStore),
apps/mobile/src/features/archive/archivedThreadList.test.ts+15−25
@@ -1,9 +1,11 @@
1import type { ArchivedSnapshotEntry } from "@t3tools/client-runtime/state/threads";1import type { ArchivedSnapshotEntry } from "@t3tools/client-runtime/state/threads";
2import type { OrchestrationProjectShell, OrchestrationThreadShell } from "@t3tools/contracts";2import type { OrchestrationProjectShell, OrchestrationV2ThreadShell } from "@t3tools/contracts";
3import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";3import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
4import { describe, expect, it } from "vite-plus/test";4import { describe, expect, it } from "vite-plus/test";
5import * as DateTime from "effect/DateTime";
5 6
6import { buildArchivedThreadGroups } from "./archivedThreadList";7import { buildArchivedThreadGroups } from "./archivedThreadList";
8import { makeRawThreadShell } from "../../test-fixtures";
7 9
8const environmentId = EnvironmentId.make("environment-1");10const environmentId = EnvironmentId.make("environment-1");
9 11
@@ -22,42 +24,30 @@ function makeProject( · 12 unmodified lines
22}24}
23 25
24function makeThread(26function makeThread(
25 input: Partial<OrchestrationThreadShell> &27 input: Pick<OrchestrationV2ThreadShell, "id" | "projectId" | "title"> & {
26 Pick<OrchestrationThreadShell, "id" | "projectId" | "title">,28 readonly branch?: string | null;
27): OrchestrationThreadShell {29 readonly archivedAt?: string | null;
28 return {30 },
29 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },31): OrchestrationV2ThreadShell {
30 runtimeMode: "full-access",32 const archivedAt = input.archivedAt === undefined ? "2026-06-02T00:00:00.000Z" : input.archivedAt;
31 interactionMode: "default",33 return makeRawThreadShell({
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,
43 ...input,34 ...input,
44 settledOverride: input.settledOverride ?? null,35 archivedAt: archivedAt === null ? null : DateTime.makeUnsafe(archivedAt),
45 settledAt: input.settledAt ?? null,36 });
46 };
47}37}
48 38
49function makeSnapshot(39function makeSnapshot(
50 projects: ReadonlyArray<OrchestrationProjectShell>,40 projects: ReadonlyArray<OrchestrationProjectShell>,
51 threads: ReadonlyArray<OrchestrationThreadShell>,41 threads: ReadonlyArray<OrchestrationV2ThreadShell>,
52 targetEnvironmentId = environmentId,42 targetEnvironmentId = environmentId,
53): ArchivedSnapshotEntry {43): ArchivedSnapshotEntry {
54 return {44 return {
55 environmentId: targetEnvironmentId,45 environmentId: targetEnvironmentId,
56 snapshot: {46 snapshot: {
47 schemaVersion: 1,
57 snapshotSequence: 1,48 snapshotSequence: 1,
58 projects,49 projects,
59 threads,50 threads,
60 updatedAt: "2026-06-04T00:00:00.000Z",
61 },51 },
62 };52 };
63}53}
apps/mobile/src/features/home/HomeScreen.tsx+57−28
@@ -118,14 +118,51 @@ interface HomeScreenProps { · 117 unmodified lines
118 118
119/* ─── Layout constants ───────────────────────────────────────────────── */119/* ─── Layout constants ───────────────────────────────────────────────── */
120 120
121const ESTIMATED_THREAD_ROW_HEIGHT = 72;121function statusColors(thread: EnvironmentThreadShell): { bg: string; fg: string } {
122const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44;122 switch (thread.runtime?.status) {
123/**123 case "running":
124 * Top spacing between the list and the Android custom header. The Android124 case "waiting":
125 * header (AndroidHomeHeader) is rendered in-flow above this screen and125 return { bg: "rgba(249,115,22,0.14)", fg: "#f97316" };
126 * already consumes the top safe-area inset, so the list only needs breathing126 case "completed":
127 * room here.127 return { bg: "rgba(34,197,94,0.14)", fg: "#22c55e" };
128 */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
138const COLLAPSED_THREAD_LIMIT = 6;
139const THREAD_LAYOUT_TRANSITION = LinearTransition.duration(220).easing(Easing.out(Easing.cubic));
140
141function 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}
129 166
130function deriveEmptyState(props: {167function deriveEmptyState(props: {
131 readonly catalogState: WorkspaceState;168 readonly catalogState: WorkspaceState;
@@ -185,7 +222,7 @@ function deriveEmptyState(props: { · 53 unmodified lines
185 222
186 return {223 return {
187 title: "No threads yet",224 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.",
189 loading: false,226 loading: false,
190 };227 };
191}228}
@@ -971,25 +1008,17 @@ export function HomeScreen(props: HomeScreenProps) { · 779 unmodified lines
971 onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined}1008 onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined}
972 variant="plain"1009 variant="plain"
973 />1010 />
974 {emptyState.loading && !shouldShowConnectionStatus ? (1011 ) : !hasResults ? (
975 <View className="mt-4 items-center">1012 <EmptyState
976 <ActivityIndicator color={accentColor} />1013 title="No threads yet"
977 </View>1014 detail="Create a task to start a new coding runtime."
978 ) : null}1015 />
979 {shouldShowConnectionStatus && Platform.OS === "ios" ? (1016 ) : (
980 <View className="mt-4">1017 projectGroups.map((group) => {
981 <WorkspaceConnectionStatus1018 const isExpanded = expandedProjects.has(group.key);
982 state={props.catalogState}1019 const visibleThreads = isExpanded
983 onPress={props.onOpenEnvironments}1020 ? group.threads
984 variant="sidebar"1021 : group.threads.slice(0, COLLAPSED_THREAD_LIMIT);
985 />
986 </View>
987 ) : null}
988 </View>
989 {connectionStatus}
990 </View>
991 );
992 }
993 1022
994 const listHeader = (1023 const listHeader = (
995 <>1024 <>
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
6import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";6import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
7import { describe, expect, it } from "vite-plus/test";7import { describe, expect, it } from "vite-plus/test";
8 8
9import {9import { buildHomeThreadGroups } from "./homeThreadList";
10 buildHomeProjectScopes,10import { makeThreadShellFixture } from "../../test-fixtures";
11 buildHomeThreadGroups,
12 sortHomeProjectScopes,
13} from "./homeThreadList";
14 11
15function makeProject(12function makeProject(
16 input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">,13 input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">,
@@ -30,25 +27,21 @@ function makeThread( · 13 unmodified lines
30 input: Partial<EnvironmentThreadShell> &27 input: Partial<EnvironmentThreadShell> &
31 Pick<EnvironmentThreadShell, "environmentId" | "id" | "projectId" | "title">,28 Pick<EnvironmentThreadShell, "environmentId" | "id" | "projectId" | "title">,
32): EnvironmentThreadShell {29): EnvironmentThreadShell {
33 return {30 return makeThreadShellFixture({
34 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },31 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
35 runtimeMode: "full-access",32 runtimeMode: "full-access",
36 interactionMode: "default",33 interactionMode: "default",
37 branch: null,34 branch: null,
38 worktreePath: null,35 worktreePath: null,
39 latestTurn: null,
40 createdAt: "2026-06-01T00:00:00.000Z",36 createdAt: "2026-06-01T00:00:00.000Z",
41 updatedAt: "2026-06-01T00:00:00.000Z",37 updatedAt: "2026-06-01T00:00:00.000Z",
42 archivedAt: null,38 archivedAt: null,
43 session: null,
44 latestUserMessageAt: null,39 latestUserMessageAt: null,
45 hasPendingApprovals: false,40 hasPendingApprovals: false,
46 hasPendingUserInput: false,41 hasPendingUserInput: false,
47 hasActionableProposedPlan: false,42 hasActionableProposedPlan: false,
48 ...input,43 ...input,
49 settledOverride: input.settledOverride ?? null,44 });
50 settledAt: input.settledAt ?? null,
51 };
52}45}
53 46
54const NOW = Date.parse("2026-06-29T00:00:00.000Z");47const NOW = Date.parse("2026-06-29T00:00:00.000Z");
apps/mobile/src/features/review/reviewModel.test.ts+7−11
@@ -1,11 +1,7 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2 2
3import {3import { MessageId, RunId, type ReviewDiffPreviewSource } from "@t3tools/contracts";
4 MessageId,4import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/shell";
5 TurnId,
6 type OrchestrationCheckpointSummary,
7 type ReviewDiffPreviewSource,
8} from "@t3tools/contracts";
9 5
10import {6import {
11 buildReviewListItems,7 buildReviewListItems,
@@ -18,9 +14,9 @@ import { · 6 unmodified lines
18} from "./reviewModel";14} from "./reviewModel";
19 15
20function makeCheckpoint(16function makeCheckpoint(
21 input: Partial<OrchestrationCheckpointSummary> &17 input: Partial<ThreadCheckpointSummary> &
22 Pick<OrchestrationCheckpointSummary, "turnId" | "checkpointTurnCount" | "completedAt">,18 Pick<ThreadCheckpointSummary, "runId" | "checkpointTurnCount" | "completedAt">,
23): OrchestrationCheckpointSummary {19): ThreadCheckpointSummary {
24 return {20 return {
25 checkpointRef: `refs/t3/checkpoints/thread/${input.checkpointTurnCount}` as any,21 checkpointRef: `refs/t3/checkpoints/thread/${input.checkpointTurnCount}` as any,
26 status: "ready",22 status: "ready",
@@ -52,12 +48,12 @@ describe("buildReviewSectionItems", () => { · 25 unmodified lines
52 it("keeps one chip per checkpoint and appends git sources", () => {48 it("keeps one chip per checkpoint and appends git sources", () => {
53 const checkpoints = [49 const checkpoints = [
54 makeCheckpoint({50 makeCheckpoint({
55 turnId: TurnId.make("turn-1"),51 runId: RunId.make("run-1"),
56 checkpointTurnCount: 1,52 checkpointTurnCount: 1,
57 completedAt: "2026-04-01T00:00:00.000Z",53 completedAt: "2026-04-01T00:00:00.000Z",
58 }),54 }),
59 makeCheckpoint({55 makeCheckpoint({
60 turnId: TurnId.make("turn-2"),56 runId: RunId.make("run-2"),
61 checkpointTurnCount: 2,57 checkpointTurnCount: 2,
62 completedAt: "2026-04-02T00:00:00.000Z",58 completedAt: "2026-04-02T00:00:00.000Z",
63 }),59 }),
apps/mobile/src/features/review/reviewModel.ts+11−10
@@ -1,6 +1,7 @@
1import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";1import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
2import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types";2import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types";
3import type { OrchestrationCheckpointSummary, ReviewDiffPreviewSource } from "@t3tools/contracts";3import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/shell";
4import type { ReviewDiffPreviewSource } from "@t3tools/contracts";
4import * as Arr from "effect/Array";5import * as Arr from "effect/Array";
5import { pipe } from "effect/Function";6import { pipe } from "effect/Function";
6import * as Order from "effect/Order";7import * as Order from "effect/Order";
@@ -128,11 +129,11 @@ export type ReviewParsedDiff = · 121 unmodified lines
128 readonly notice: string | null;129 readonly notice: string | null;
129 };130 };
130 131
131function checkpointTitle(checkpoint: OrchestrationCheckpointSummary): string {132function checkpointTitle(checkpoint: ThreadCheckpointSummary): string {
132 return `Turn ${checkpoint.checkpointTurnCount}`;133 return `Turn ${checkpoint.checkpointTurnCount}`;
133}134}
134 135
135function checkpointSubtitle(checkpoint: OrchestrationCheckpointSummary): string {136function checkpointSubtitle(checkpoint: ThreadCheckpointSummary): string {
136 const fileCount = checkpoint.files.length;137 const fileCount = checkpoint.files.length;
137 if (checkpoint.status !== "ready") {138 if (checkpoint.status !== "ready") {
138 return `Diff ${checkpoint.status}`;139 return `Diff ${checkpoint.status}`;
@@ -141,8 +142,8 @@ function checkpointSubtitle(checkpoint: OrchestrationCheckpointSummary): string · 2 unmodified lines
141}142}
142 143
143function compareCheckpointTurnCountDescending(144function compareCheckpointTurnCountDescending(
144 left: OrchestrationCheckpointSummary,145 left: ThreadCheckpointSummary,
145 right: OrchestrationCheckpointSummary,146 right: ThreadCheckpointSummary,
146): -1 | 0 | 1 {147): -1 | 0 | 1 {
147 if (left.checkpointTurnCount === right.checkpointTurnCount) {148 if (left.checkpointTurnCount === right.checkpointTurnCount) {
148 return 0;149 return 0;
@@ -151,7 +152,7 @@ function compareCheckpointTurnCountDescending( · 2 unmodified lines
151 return left.checkpointTurnCount > right.checkpointTurnCount ? -1 : 1;152 return left.checkpointTurnCount > right.checkpointTurnCount ? -1 : 1;
152}153}
153 154
154const readyCheckpointOrder = Order.make<OrchestrationCheckpointSummary>(155const readyCheckpointOrder = Order.make<ThreadCheckpointSummary>(
155 compareCheckpointTurnCountDescending,156 compareCheckpointTurnCountDescending,
156);157);
157 158
@@ -510,14 +511,14 @@ function mapRenderableFile(file: FileDiffMetadata): ReviewRenderableFile { · 352 unmodified lines
510}511}
511 512
512export function getReviewSectionIdForCheckpoint(513export function getReviewSectionIdForCheckpoint(
513 checkpoint: Pick<OrchestrationCheckpointSummary, "checkpointTurnCount">,514 checkpoint: Pick<ThreadCheckpointSummary, "checkpointTurnCount">,
514): string {515): string {
515 return `turn:${checkpoint.checkpointTurnCount}`;516 return `turn:${checkpoint.checkpointTurnCount}`;
516}517}
517 518
518export function getReadyReviewCheckpoints(519export function getReadyReviewCheckpoints(
519 checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>,520 checkpoints: ReadonlyArray<ThreadCheckpointSummary>,
520): ReadonlyArray<OrchestrationCheckpointSummary> {521): ReadonlyArray<ThreadCheckpointSummary> {
521 return pipe(522 return pipe(
522 checkpoints,523 checkpoints,
523 Arr.filter((checkpoint) => checkpoint.status === "ready"),524 Arr.filter((checkpoint) => checkpoint.status === "ready"),
@@ -526,7 +527,7 @@ export function getReadyReviewCheckpoints( · 2 unmodified lines
526}527}
527 528
528export function buildReviewSectionItems(input: {529export function buildReviewSectionItems(input: {
529 readonly checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>;530 readonly checkpoints: ReadonlyArray<ThreadCheckpointSummary>;
530 readonly gitSections: ReadonlyArray<ReviewDiffPreviewSource>;531 readonly gitSections: ReadonlyArray<ReviewDiffPreviewSource>;
531 readonly turnDiffById: Readonly<Record<string, string | undefined>>;532 readonly turnDiffById: Readonly<Record<string, string | undefined>>;
532 readonly loadingTurnIds: Readonly<Record<string, boolean | undefined>>;533 readonly loadingTurnIds: Readonly<Record<string, boolean | undefined>>;
apps/mobile/src/features/review/useReviewSections.ts+3−2
@@ -1,6 +1,7 @@
1import { useCallback, useEffect, useMemo } from "react";1import { useCallback, useEffect, useMemo } from "react";
2 2
3import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts";3import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/shell";
4import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
4 5
5import { useCheckpointDiff } from "../../state/queries";6import { useCheckpointDiff } from "../../state/queries";
6import { useEnvironmentQuery } from "../../state/query";7import { useEnvironmentQuery } from "../../state/query";
@@ -59,7 +60,7 @@ export function useReviewSections(input: { · 52 unmodified lines
59 getReviewSectionIdForCheckpoint(checkpoint),60 getReviewSectionIdForCheckpoint(checkpoint),
60 checkpoint,61 checkpoint,
61 ]),62 ]),
62 ) as Record<string, OrchestrationCheckpointSummary>,63 ) as Record<string, ThreadCheckpointSummary>,
63 [readyCheckpoints],64 [readyCheckpoints],
64 );65 );
65 const reviewSections = useMemo(66 const reviewSections = useMemo(
apps/mobile/src/features/threads/PendingApprovalCard.tsx+14−6
@@ -1,4 +1,4 @@
1import type { ApprovalRequestId, ProviderApprovalDecision } from "@t3tools/contracts";1import type { ProviderApprovalDecision, RuntimeRequestId } from "@t3tools/contracts";
2import { Pressable, View } from "react-native";2import { Pressable, View } from "react-native";
3 3
4import { AppText as Text } from "../../components/AppText";4import { AppText as Text } from "../../components/AppText";
@@ -6,14 +6,16 @@ import type { PendingApproval } from "../../lib/threadActivity"; · 1 unmodified line
6 6
7export interface PendingApprovalCardProps {7export interface PendingApprovalCardProps {
8 readonly approval: PendingApproval;8 readonly approval: PendingApproval;
9 readonly respondingApprovalId: ApprovalRequestId | null;9 readonly respondingApprovalId: RuntimeRequestId | null;
10 readonly onRespond: (10 readonly onRespond: (
11 requestId: ApprovalRequestId,11 requestId: RuntimeRequestId,
12 decision: ProviderApprovalDecision,12 decision: ProviderApprovalDecision,
13 ) => Promise<unknown>;13 ) => Promise<unknown>;
14}14}
15 15
16export function PendingApprovalCard(props: PendingApprovalCardProps) {16export function PendingApprovalCard(props: PendingApprovalCardProps) {
17 const canRespond = props.approval.responseCapability === "live";
18 const disabled = !canRespond || props.respondingApprovalId === props.approval.requestId;
17 return (19 return (
18 <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">20 <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">
19 <Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-sky-700 dark:text-sky-300">21 <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
27 {props.approval.detail}29 {props.approval.detail}
28 </Text>30 </Text>
29 ) : null}31 ) : 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}
30 <View className="flex-row flex-wrap gap-2.5">38 <View className="flex-row flex-wrap gap-2.5">
31 <Pressable39 <Pressable
32 className="items-center justify-center rounded-[14px] bg-blue-500 px-3.5 py-3"40 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}
34 onPress={() => void props.onRespond(props.approval.requestId, "accept")}42 onPress={() => void props.onRespond(props.approval.requestId, "accept")}
35 >43 >
36 <Text className="font-t3-extrabold text-sm text-white">Allow once</Text>44 <Text className="font-t3-extrabold text-sm text-white">Allow once</Text>
37 </Pressable>45 </Pressable>
38 <Pressable46 <Pressable
39 className="items-center justify-center rounded-[14px] bg-neutral-200 px-3.5 py-3 dark:bg-neutral-800"47 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}
41 onPress={() => void props.onRespond(props.approval.requestId, "acceptForSession")}49 onPress={() => void props.onRespond(props.approval.requestId, "acceptForSession")}
42 >50 >
43 <Text className="font-t3-bold text-sm text-neutral-950 dark:text-neutral-50">51 <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
46 </Pressable>54 </Pressable>
47 <Pressable55 <Pressable
48 className="items-center justify-center rounded-[14px] bg-rose-100 px-3.5 py-3 dark:bg-rose-500/18"56 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}
50 onPress={() => void props.onRespond(props.approval.requestId, "decline")}58 onPress={() => void props.onRespond(props.approval.requestId, "decline")}
51 >59 >
52 <Text className="font-t3-bold text-sm text-rose-700 dark:text-rose-300">Decline</Text>60 <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 @@
1import type { ApprovalRequestId } from "@t3tools/contracts";1import type { RuntimeRequestId } from "@t3tools/contracts";
2import { Pressable, View } from "react-native";2import { Pressable, View } from "react-native";
3 3
4import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";4import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
@@ -9,14 +9,10 @@ export interface PendingUserInputCardProps { · 4 unmodified lines
9 readonly pendingUserInput: PendingUserInput;9 readonly pendingUserInput: PendingUserInput;
10 readonly drafts: Record<string, PendingUserInputDraftAnswer>;10 readonly drafts: Record<string, PendingUserInputDraftAnswer>;
11 readonly answers: Record<string, string> | null;11 readonly answers: Record<string, string> | null;
12 readonly respondingUserInputId: ApprovalRequestId | null;12 readonly respondingUserInputId: RuntimeRequestId | null;
13 readonly onSelectOption: (13 readonly onSelectOption: (requestId: RuntimeRequestId, questionId: string, label: string) => void;
14 requestId: ApprovalRequestId,
15 questionId: string,
16 label: string,
17 ) => void;
18 readonly onChangeCustomAnswer: (14 readonly onChangeCustomAnswer: (
19 requestId: ApprovalRequestId,15 requestId: RuntimeRequestId,
20 questionId: string,16 questionId: string,
21 customAnswer: string,17 customAnswer: string,
22 ) => void;18 ) => void;
@@ -24,6 +20,7 @@ export interface PendingUserInputCardProps { · 1 unmodified line
24}20}
25 21
26export function PendingUserInputCard(props: PendingUserInputCardProps) {22export function PendingUserInputCard(props: PendingUserInputCardProps) {
23 const canRespond = props.pendingUserInput.responseCapability === "live";
27 return (24 return (
28 <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">25 <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">
29 <Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-sky-700 dark:text-sky-300">26 <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
32 <Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50">29 <Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50">
33 Fill in the pending answers30 Fill in the pending answers
34 </Text>31 </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}
35 {props.pendingUserInput.questions.map((question) => {38 {props.pendingUserInput.questions.map((question) => {
36 const draft = props.drafts[question.id];39 const draft = props.drafts[question.id];
37 return (40 return (
@@ -49,6 +52,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 11 unmodified lines
49 return (52 return (
50 <Pressable53 <Pressable
51 key={option.label}54 key={option.label}
55 disabled={!canRespond}
52 className={cn(56 className={cn(
53 "rounded-full border px-3 py-2.5 ",57 "rounded-full border px-3 py-2.5 ",
54 selected58 selected
@@ -78,6 +82,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 23 unmodified lines
78 })}82 })}
79 </View>83 </View>
80 <TextInput84 <TextInput
85 editable={canRespond}
81 value={draft?.customAnswer ?? ""}86 value={draft?.customAnswer ?? ""}
82 onChangeText={(value) =>87 onChangeText={(value) =>
83 props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value)88 props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value)
@@ -94,7 +99,9 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 10 unmodified lines
94 props.answers ? "bg-blue-500" : "bg-neutral-200 dark:bg-neutral-700/60",99 props.answers ? "bg-blue-500" : "bg-neutral-200 dark:bg-neutral-700/60",
95 )}100 )}
96 disabled={101 disabled={
97 props.answers === null || props.respondingUserInputId === props.pendingUserInput.requestId102 !canRespond ||
103 props.answers === null ||
104 props.respondingUserInputId === props.pendingUserInput.requestId
98 }105 }
99 onPress={() => void props.onSubmit()}106 onPress={() => void props.onSubmit()}
100 >107 >
apps/mobile/src/features/threads/ThreadComposer.tsx+6−11
@@ -1,9 +1,12 @@
1import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";1import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
2import {
3 threadRuntimeIsActive,
4 type EnvironmentThreadShell,
5} from "@t3tools/client-runtime/state/shell";
2import type {6import type {
3 EnvironmentId,7 EnvironmentId,
4 MessageId,8 MessageId,
5 ModelSelection,9 ModelSelection,
6 OrchestrationThreadShell,
7 ProviderInteractionMode,10 ProviderInteractionMode,
8 RuntimeMode,11 RuntimeMode,
9 ServerConfig as T3ServerConfig,12 ServerConfig as T3ServerConfig,
@@ -91,13 +94,7 @@ export interface ThreadComposerProps { · 81 unmodified lines
91 readonly connectionState: RemoteClientConnectionState;94 readonly connectionState: RemoteClientConnectionState;
92 readonly connectionError: string | null;95 readonly connectionError: string | null;
93 readonly environmentLabel: string | null;96 readonly environmentLabel: string | null;
94 /**97 readonly selectedThread: EnvironmentThreadShell;
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;
101 readonly serverConfig: T3ServerConfig | null;98 readonly serverConfig: T3ServerConfig | null;
102 readonly queueCount: number;99 readonly queueCount: number;
103 readonly activeThreadBusy: boolean;100 readonly activeThreadBusy: boolean;
@@ -305,9 +302,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 201 unmodified lines
305 setIsFocused(false);302 setIsFocused(false);
306 onExpandedChange?.(false);303 onExpandedChange?.(false);
307 }, [onExpandedChange]);304 }, [onExpandedChange]);
308 const showStopAction =305 const showStopAction = threadRuntimeIsActive(props.selectedThread.runtime);
309 props.selectedThread.session?.status === "running" ||
310 props.selectedThread.session?.status === "starting";
311 306
312 const sendLabel =307 const sendLabel =
313 props.connectionState !== "connected" || props.activeThreadBusy || props.queueCount > 0308 props.connectionState !== "connected" || props.activeThreadBusy || props.queueCount > 0
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+51−44
@@ -1,16 +1,15 @@
1import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";1import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
2import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";2import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
3import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";3import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
4import type { LegendListRef } from "@legendapp/list/react-native";4import type { LegendListRef } from "@legendapp/list/react-native";
5import type {5import type {
6 ApprovalRequestId,
7 EnvironmentId,6 EnvironmentId,
8 MessageId,7 MessageId,
9 ModelSelection,8 ModelSelection,
10 OrchestrationThreadShell,
11 ProviderApprovalDecision,9 ProviderApprovalDecision,
12 ProviderInteractionMode,10 ProviderInteractionMode,
13 RuntimeMode,11 RuntimeMode,
12 RuntimeRequestId,
14 ServerConfig as T3ServerConfig,13 ServerConfig as T3ServerConfig,
15 ThreadId,14 ThreadId,
16} from "@t3tools/contracts";15} from "@t3tools/contracts";
@@ -43,7 +42,7 @@ import { ThreadFeed } from "./ThreadFeed"; · 26 unmodified lines
43import type { ThreadContentPresentation } from "./threadContentPresentation";42import type { ThreadContentPresentation } from "./threadContentPresentation";
44 43
45export interface ThreadDetailScreenProps {44export interface ThreadDetailScreenProps {
46 readonly selectedThread: OrchestrationThreadShell;45 readonly selectedThread: EnvironmentThreadShell;
47 readonly contentPresentation: ThreadContentPresentation;46 readonly contentPresentation: ThreadContentPresentation;
48 readonly screenTone: StatusTone;47 readonly screenTone: StatusTone;
49 readonly connectionError: string | null;48 readonly connectionError: string | null;
@@ -51,11 +50,11 @@ export interface ThreadDetailScreenProps { · 1 unmodified line
51 readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>;50 readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>;
52 readonly activeWorkStartedAt: string | null;51 readonly activeWorkStartedAt: string | null;
53 readonly activePendingApproval: PendingApproval | null;52 readonly activePendingApproval: PendingApproval | null;
54 readonly respondingApprovalId: ApprovalRequestId | null;53 readonly respondingApprovalId: RuntimeRequestId | null;
55 readonly activePendingUserInput: PendingUserInput | null;54 readonly activePendingUserInput: PendingUserInput | null;
56 readonly activePendingUserInputDrafts: Record<string, PendingUserInputDraftAnswer>;55 readonly activePendingUserInputDrafts: Record<string, PendingUserInputDraftAnswer>;
57 readonly activePendingUserInputAnswers: Record<string, string> | null;56 readonly activePendingUserInputAnswers: Record<string, string> | null;
58 readonly respondingUserInputId: ApprovalRequestId | null;57 readonly respondingUserInputId: RuntimeRequestId | null;
59 readonly draftMessage: string;58 readonly draftMessage: string;
60 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;59 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
61 readonly connectionStateLabel: EnvironmentConnectionPhase;60 readonly connectionStateLabel: EnvironmentConnectionPhase;
@@ -82,16 +81,16 @@ export interface ThreadDetailScreenProps { · 20 unmodified lines
82 readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void;81 readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void;
83 readonly onUpdateThreadInteractionMode: (interactionMode: ProviderInteractionMode) => void;82 readonly onUpdateThreadInteractionMode: (interactionMode: ProviderInteractionMode) => void;
84 readonly onRespondToApproval: (83 readonly onRespondToApproval: (
85 requestId: ApprovalRequestId,84 requestId: RuntimeRequestId,
86 decision: ProviderApprovalDecision,85 decision: ProviderApprovalDecision,
87 ) => Promise<unknown>;86 ) => Promise<unknown>;
88 readonly onSelectUserInputOption: (87 readonly onSelectUserInputOption: (
89 requestId: ApprovalRequestId,88 requestId: RuntimeRequestId,
90 questionId: string,89 questionId: string,
91 label: string,90 label: string,
92 ) => void;91 ) => void;
93 readonly onChangeUserInputCustomAnswer: (92 readonly onChangeUserInputCustomAnswer: (
94 requestId: ApprovalRequestId,93 requestId: RuntimeRequestId,
95 questionId: string,94 questionId: string,
96 customAnswer: string,95 customAnswer: string,
97 ) => void;96 ) => void;
@@ -341,41 +340,49 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 243 unmodified lines
341 }, []);340 }, []);
342 341
343 return (342 return (
344 <View className="flex-1">343 <GestureDetector gesture={headerDrawerGesture}>
345 {showContent ? (344 <View className="flex-1">
346 <View345 {showContent ? (
347 className="flex-1"346 <View
348 onTouchStart={handleFeedTouchStart}347 style={{ flex: 1 }}
349 onTouchMove={handleFeedTouchMove}348 onTouchStart={handleFeedTouchStart}
350 onTouchEnd={handleFeedTouchEnd}349 onTouchMove={handleFeedTouchMove}
351 onTouchCancel={handleFeedTouchCancel}350 onTouchEnd={handleFeedTouchEnd}
352 >351 onTouchCancel={handleFeedTouchCancel}
353 <ThreadFeed352 >
354 key={props.selectedThread.id}353 <ThreadFeed
355 environmentId={props.environmentId}354 key={props.selectedThread.id}
356 threadId={props.selectedThread.id}355 environmentId={props.environmentId}
357 workspaceRoot={props.threadCwd}356 threadId={props.selectedThread.id}
358 feed={props.selectedThreadFeed}357 workspaceRoot={props.threadCwd}
359 contentPresentation={props.contentPresentation}358 feed={props.selectedThreadFeed}
360 agentLabel={agentLabel}359 contentPresentation={props.contentPresentation}
361 latestTurn={props.selectedThread.latestTurn}360 agentLabel={agentLabel}
362 activeWorkStartedAt={props.activeWorkStartedAt}361 latestRun={props.selectedThread.latestRun}
363 listRef={listRef}362 listRef={listRef}
364 freeze={freeze}363 freeze={freeze}
365 anchorMessageId={anchorMessageId}364 anchorMessageId={anchorMessageId}
366 contentInsetEndAdjustment={contentInsetEndAdjustment}365 contentInsetEndAdjustment={contentInsetEndAdjustment}
367 contentTopInset={0}366 contentTopInset={headerHeight}
368 contentBottomInset={estimatedOverlayHeight}367 contentBottomInset={estimatedOverlayHeight}
369 contentMaxWidth={contentMaxWidth}368 layoutVariant={layoutVariant}
370 layoutVariant={layoutVariant}369 skills={selectedProviderSkills}
371 usesAutomaticContentInsets={props.usesAutomaticContentInsets}370 />
372 onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}371 </View>
373 skills={selectedProviderSkills}372 ) : (
374 />373 <View style={{ flex: 1 }} />
375 </View>374 )}
376 ) : (375
377 <View className="flex-1" />376 {/* Floating composer — sticks to keyboard via KeyboardStickyView */}
378 )}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}
379 386
380 {/* Floating composer — sticks to keyboard via KeyboardStickyView */}387 {/* Floating composer — sticks to keyboard via KeyboardStickyView */}
381 {showContent ? (388 {showContent ? (
apps/mobile/src/features/threads/ThreadFeed.tsx+51−58
@@ -1,7 +1,7 @@
1import * as Haptics from "expo-haptics";1import * as Haptics from "expo-haptics";
2import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";2import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
3import { type LegendListRef } from "@legendapp/list/react-native";3import { type LegendListRef } from "@legendapp/list/react-native";
4import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";4import type { EnvironmentId, MessageId, RunId, ThreadId } from "@t3tools/contracts";
5import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";5import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
6import { formatElapsed } from "@t3tools/shared/orchestrationTiming";6import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
7import { SymbolView } from "../../components/AppSymbol";7import { SymbolView } from "../../components/AppSymbol";
@@ -92,7 +92,7 @@ import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/l · 84 unmodified lines
92import {92import {
93 deriveThreadFeedPresentation,93 deriveThreadFeedPresentation,
94 type ThreadFeedEntry,94 type ThreadFeedEntry,
95 type ThreadFeedLatestTurn,95 type ThreadFeedLatestRun,
96} from "../../lib/threadActivity";96} from "../../lib/threadActivity";
97import type { ThreadContentPresentation } from "./threadContentPresentation";97import type { ThreadContentPresentation } from "./threadContentPresentation";
98import {98import {
@@ -151,8 +151,7 @@ export interface ThreadFeedProps { · 52 unmodified lines
151 readonly feed: ReadonlyArray<ThreadFeedEntry>;151 readonly feed: ReadonlyArray<ThreadFeedEntry>;
152 readonly contentPresentation: ThreadContentPresentation;152 readonly contentPresentation: ThreadContentPresentation;
153 readonly agentLabel: string;153 readonly agentLabel: string;
154 readonly latestTurn: ThreadFeedLatestTurn | null;154 readonly latestRun: ThreadFeedLatestRun | null;
155 readonly activeWorkStartedAt: string | null;
156 readonly listRef: RefObject<LegendListRef | null>;155 readonly listRef: RefObject<LegendListRef | null>;
157 readonly freeze: SharedValue<boolean>;156 readonly freeze: SharedValue<boolean>;
158 readonly anchorMessageId: MessageId | null;157 readonly anchorMessageId: MessageId | null;
@@ -823,11 +822,11 @@ function renderFeedEntry( · 664 unmodified lines
823 readonly copiedRowId: string | null;822 readonly copiedRowId: string | null;
824 readonly expandedWorkRows: Record<string, boolean>;823 readonly expandedWorkRows: Record<string, boolean>;
825 readonly terminalAssistantMessageIds: ReadonlySet<string>;824 readonly terminalAssistantMessageIds: ReadonlySet<string>;
826 readonly unsettledTurnId: TurnId | null;825 readonly unsettledTurnId: RunId | null;
827 readonly onCopyWorkRow: (rowId: string, value: string) => void;826 readonly onCopyWorkRow: (rowId: string, value: string) => void;
828 readonly onToggleWorkGroup: (groupId: string) => void;827 readonly onToggleWorkGroup: (groupId: string) => void;
829 readonly onToggleWorkRow: (rowId: string) => void;828 readonly onToggleWorkRow: (rowId: string) => void;
830 readonly onToggleTurnFold: (turnId: TurnId) => void;829 readonly onToggleTurnFold: (runId: RunId) => void;
831 readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;830 readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;
832 readonly onMarkdownLinkPress: (href: string) => void;831 readonly onMarkdownLinkPress: (href: string) => void;
833 readonly iconSubtleColor: string | import("react-native").ColorValue;832 readonly iconSubtleColor: string | import("react-native").ColorValue;
@@ -841,16 +840,12 @@ function renderFeedEntry( · 7 unmodified lines
841 const entry = info.item;840 const entry = info.item;
842 const { markdownStyles, iconSubtleColor, userBubbleColor } = props;841 const { markdownStyles, iconSubtleColor, userBubbleColor } = props;
843 842
844 if (entry.type === "working") {843 if (entry.type === "run-fold") {
845 return <WorkingTimelineRow startedAt={entry.createdAt} />;
846 }
847
848 if (entry.type === "turn-fold") {
849 return (844 return (
850 <Pressable845 <Pressable
851 accessibilityRole="button"846 accessibilityRole="button"
852 accessibilityState={{ expanded: entry.expanded }}847 accessibilityState={{ expanded: entry.expanded }}
853 onPress={() => props.onToggleTurnFold(entry.turnId)}848 onPress={() => props.onToggleTurnFold(entry.runId)}
854 hitSlop={4}849 hitSlop={4}
855 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]"850 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]"
856 >851 >
@@ -889,7 +884,7 @@ function renderFeedEntry( · 32 unmodified lines
889 const assistantTurnStillInProgress =884 const assistantTurnStillInProgress =
890 message.role === "assistant" &&885 message.role === "assistant" &&
891 props.unsettledTurnId !== null &&886 props.unsettledTurnId !== null &&
892 message.turnId === props.unsettledTurnId;887 message.runId === props.unsettledTurnId;
893 const showAssistantMeta =888 const showAssistantMeta =
894 message.role === "assistant" &&889 message.role === "assistant" &&
895 props.terminalAssistantMessageIds.has(message.id) &&890 props.terminalAssistantMessageIds.has(message.id) &&
@@ -1320,21 +1315,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 424 unmodified lines
1320 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);1315 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1321 const foldSettleFrameRef = useRef<number | null>(null);1316 const foldSettleFrameRef = useRef<number | null>(null);
1322 const foldSettleSecondFrameRef = useRef<number | null>(null);1317 const foldSettleSecondFrameRef = useRef<number | null>(null);
1323 const disclosureAnchorKeyRef = useRef<string | null>(null);1318 const previousLatestTurnRef = useRef(props.latestRun);
1324 const headerMaterialVisibleRef = useRef(false);1319 const { width: viewportWidth } = useWindowDimensions();
1325 const previousLatestTurnRef = useRef(props.latestTurn);1320 const [foldToggleSettling, setFoldToggleSettling] = useState(false);
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);
1333 const [interactionState, setInteractionState] = useState<{1321 const [interactionState, setInteractionState] = useState<{
1334 readonly copiedRowId: string | null;1322 readonly copiedRowId: string | null;
1335 readonly expandedWorkGroups: Record<string, boolean>;1323 readonly expandedWorkGroups: Record<string, boolean>;
1336 readonly expandedWorkRows: Record<string, boolean>;1324 readonly expandedWorkRows: Record<string, boolean>;
1337 readonly expandedTurnIds: ReadonlySet<TurnId>;1325 readonly expandedTurnIds: ReadonlySet<RunId>;
1338 }>({1326 }>({
1339 copiedRowId: null,1327 copiedRowId: null,
1340 expandedWorkGroups: {},1328 expandedWorkGroups: {},
@@ -1424,15 +1412,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 83 unmodified lines
1424 viewportWidth,1412 viewportWidth,
1425 ],1413 ],
1426 );1414 );
1427 const reportHeaderMaterialVisibility = useCallback(1415 const presentedFeed = useMemo(
1428 (visible: boolean) => {1416 () => deriveThreadFeedPresentation(props.feed, props.latestRun, expandedTurnIds),
1429 if (headerMaterialVisibleRef.current === visible) {1417 [expandedTurnIds, props.feed, props.latestRun],
1430 return;
1431 }
1432 headerMaterialVisibleRef.current = visible;
1433 props.onHeaderMaterialVisibilityChange?.(visible);
1434 },
1435 [props.onHeaderMaterialVisibilityChange],
1436 );1418 );
1437 // True while the viewport sits within ~one screen of the list end — the1419 // True while the viewport sits within ~one screen of the list end — the
1438 // only region where layout shifts should animate. Starts true because the1420 // 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
1544 [presentedFeed, props.anchorMessageId, anchorTopInset],1526 [presentedFeed, props.anchorMessageId, anchorTopInset],
1545 );1527 );
1546 const terminalAssistantMessageIds = useMemo(() => {1528 const terminalAssistantMessageIds = useMemo(() => {
1547 const terminalIdsByTurn = new Map<TurnId, string>();1529 const terminalIdsByTurn = new Map<RunId, string>();
1548 for (const entry of props.feed) {1530 for (const entry of props.feed) {
1549 if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) {1531 if (entry.type === "message" && entry.message.role === "assistant" && entry.message.runId) {
1550 terminalIdsByTurn.set(entry.message.turnId, entry.message.id);1532 terminalIdsByTurn.set(entry.message.runId, entry.message.id);
1551 }1533 }
1552 }1534 }
1553 return new Set(terminalIdsByTurn.values());1535 return new Set(terminalIdsByTurn.values());
1554 }, [props.feed]);1536 }, [props.feed]);
1555 const unsettledTurnId =1537 const unsettledTurnId =
1556 props.latestTurn &&1538 props.latestRun &&
1557 (props.latestTurn.completedAt === null || props.latestTurn.state === "running")1539 (props.latestRun.completedAt === null || props.latestRun.status === "running")
1558 ? props.latestTurn.turnId1540 ? props.latestRun.runId
1559 : null;1541 : null;
1560 1542
1561 useEffect(() => {1543 useEffect(() => {
1562 const previous = previousLatestTurnRef.current;1544 const previous = previousLatestTurnRef.current;
1563 previousLatestTurnRef.current = props.latestTurn;1545 previousLatestTurnRef.current = props.latestRun;
1564 if (!props.latestTurn || !previous) {1546 if (!props.latestRun || !previous) {
1565 return;1547 return;
1566 }1548 }
1567 if (props.latestTurn.turnId === previous.turnId) {1549 if (props.latestRun.runId === previous.runId) {
1568 if (previous.state === "running" && props.latestTurn.state === "interrupted") {1550 if (previous.status === "running" && props.latestRun.status === "interrupted") {
1569 const interruptedTurnId = props.latestTurn.turnId;1551 const interruptedTurnId = props.latestRun.runId;
1570 setInteractionState((current) => ({1552 setInteractionState((current) => ({
1571 ...current,1553 ...current,
1572 expandedTurnIds: new Set(current.expandedTurnIds).add(interruptedTurnId),1554 expandedTurnIds: new Set(current.expandedTurnIds).add(interruptedTurnId),
@@ -1575,14 +1557,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 2 unmodified lines
1575 return;1557 return;
1576 }1558 }
1577 setInteractionState((current) => {1559 setInteractionState((current) => {
1578 if (!current.expandedTurnIds.has(previous.turnId)) {1560 if (!current.expandedTurnIds.has(previous.runId)) {
1579 return current;1561 return current;
1580 }1562 }
1581 const next = new Set(current.expandedTurnIds);1563 const next = new Set(current.expandedTurnIds);
1582 next.delete(previous.turnId);1564 next.delete(previous.runId);
1583 return { ...current, expandedTurnIds: next };1565 return { ...current, expandedTurnIds: next };
1584 });1566 });
1585 }, [props.latestTurn]);1567 }, [props.latestRun]);
1586 1568
1587 useEffect(() => {1569 useEffect(() => {
1588 return () => {1570 return () => {
@@ -1676,17 +1658,28 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 87 unmodified lines
1676 [suspendEndScrollMaintenanceForDisclosure],1658 [suspendEndScrollMaintenanceForDisclosure],
1677 );1659 );
1678 1660
1679 const onToggleTurnFold = useCallback(1661 const onToggleTurnFold = useCallback((runId: RunId) => {
1680 (turnId: TurnId) => {1662 setFoldToggleSettling(true);
1681 suspendEndScrollMaintenanceForDisclosure(`turn-fold:${turnId}`);1663 if (foldSettleFrameRef.current !== null) {
1682 setInteractionState((current) => {1664 cancelAnimationFrame(foldSettleFrameRef.current);
1683 const next = new Set(current.expandedTurnIds);1665 }
1684 if (next.has(turnId)) {1666 if (foldSettleSecondFrameRef.current !== null) {
1685 next.delete(turnId);1667 cancelAnimationFrame(foldSettleSecondFrameRef.current);
1686 } else {1668 }
1687 next.add(turnId);1669 setInteractionState((current) => {
1688 }1670 const next = new Set(current.expandedTurnIds);
1689 return { ...current, expandedTurnIds: next };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;
1690 });1683 });
1691 },1684 },
1692 [suspendEndScrollMaintenanceForDisclosure],1685 [suspendEndScrollMaintenanceForDisclosure],
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+10−20
@@ -1,11 +1,6 @@
1import { NativeStackScreenOptions } from "../../native/StackHeader";1import { Stack, useLocalSearchParams, useRouter } from "expo-router";
2import {2import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell";
3 StackActions,3import { useCallback, useMemo, useState } from "react";
4 useFocusEffect,
5 useNavigation,
6 type StaticScreenProps,
7} from "@react-navigation/native";
8import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
9import * as Option from "effect/Option";4import * as Option from "effect/Option";
10import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts";5import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts";
11import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";6import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
@@ -461,20 +456,15 @@ function ThreadRouteContent( · 449 unmodified lines
461 void navigation.navigate("Connections");456 void navigation.navigate("Connections");
462 }, [navigation]);457 }, [navigation]);
463 const handleStopThread = useCallback(() => {458 const handleStopThread = useCallback(() => {
464 if (459 const runtime = selectedThread?.runtime;
465 !selectedThread ||460 if (!selectedThread || !runtime || !threadRuntimeIsActive(runtime)) {
466 (selectedThread.session?.status !== "running" &&
467 selectedThread.session?.status !== "starting")
468 ) {
469 return;461 return;
470 }462 }
471 return interruptThreadTurn({463 return interruptThreadTurn({
472 environmentId: selectedThread.environmentId,464 environmentId: selectedThread.environmentId,
473 input: {465 input: {
474 threadId: selectedThread.id,466 threadId: selectedThread.id,
475 ...(selectedThread.session.activeTurnId467 ...(runtime.activeRunId ? { runId: runtime.activeRunId } : {}),
476 ? { turnId: selectedThread.session.activeTurnId }
477 : {}),
478 },468 },
479 });469 });
480 }, [interruptThreadTurn, selectedThread]);470 }, [interruptThreadTurn, selectedThread]);
@@ -504,7 +494,7 @@ function ThreadRouteContent( · 23 unmodified lines
504 terminalDebugLog("terminal-menu:open-new", {494 terminalDebugLog("terminal-menu:open-new", {
505 hasThread: Boolean(selectedThread),495 hasThread: Boolean(selectedThread),
506 hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot),496 hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot),
507 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),497 listedTerminalIds: terminalMenuSessions.map((runtime) => runtime.terminalId),
508 });498 });
509 499
510 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {500 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {
@@ -512,7 +502,7 @@ function ThreadRouteContent( · 1 unmodified line
512 }502 }
513 503
514 const nextId = nextOpenTerminalId({504 const nextId = nextOpenTerminalId({
515 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),505 listedTerminalIds: terminalMenuSessions.map((runtime) => runtime.terminalId),
516 });506 });
517 void navigation.navigate("ThreadTerminal", {507 void navigation.navigate("ThreadTerminal", {
518 environmentId: String(selectedThread.environmentId),508 environmentId: String(selectedThread.environmentId),
@@ -539,9 +529,9 @@ function ThreadRouteContent( · 20 unmodified lines
539 }529 }
540 530
541 const targetTerminalId = resolveProjectScriptTerminalId({531 const targetTerminalId = resolveProjectScriptTerminalId({
542 existingTerminalIds: terminalMenuSessions.map((session) => session.terminalId),532 existingTerminalIds: terminalMenuSessions.map((runtime) => runtime.terminalId),
543 hasRunningTerminal: terminalMenuSessions.some(533 hasRunningTerminal: terminalMenuSessions.some(
544 (session) => session.status === "running" || session.status === "starting",534 (runtime) => runtime.status === "running" || runtime.status === "starting",
545 ),535 ),
546 });536 });
547 const preferredWorktreePath = resolvePreferredThreadWorktreePath({537 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
7 return Number.isNaN(candidate) ? 0 : candidate;7 return Number.isNaN(candidate) ? 0 : candidate;
8}8}
9 9
10export type ThreadStatusKind =10export function threadStatusTone(thread: EnvironmentThreadShell): StatusTone {
11 | "pending-approval"11 const status = thread.runtime?.status;
12 | "awaiting-input"12 if (status === "running" || status === "waiting") {
13 | "working"
14 | "connecting"
15 | "error"
16 | "plan-ready";
17
18export 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. */
29export const THREAD_STATUS_NEUTRAL_ICON = {
30 iconColor: "#8e8e93",
31 iconBackground: "rgba(142,142,147,0.22)",
32} as const;
33
34function 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 */
49export function resolveThreadStatus(
50 thread: EnvironmentThreadShell,
51): ThreadStatusPresentation | null {
52 if (thread.hasPendingApprovals) {
53 return {13 return {
54 kind: "pending-approval",14 kind: "pending-approval",
55 label: "Needs Approval",15 label: "Needs Approval",
@@ -60,8 +20,7 @@ export function resolveThreadStatus( · 4 unmodified lines
60 pulse: false,20 pulse: false,
61 };21 };
62 }22 }
63 23 if (status === "completed") {
64 if (thread.hasPendingUserInput) {
65 return {24 return {
66 kind: "awaiting-input",25 kind: "awaiting-input",
67 label: "Awaiting Input",26 label: "Awaiting Input",
@@ -72,8 +31,7 @@ export function resolveThreadStatus( · 4 unmodified lines
72 pulse: false,31 pulse: false,
73 };32 };
74 }33 }
75 34 if (status === "queued" || status === "starting") {
76 if (thread.session?.status === "running") {
77 return {35 return {
78 kind: "working",36 kind: "working",
79 label: "Working",37 label: "Working",
@@ -84,8 +42,7 @@ export function resolveThreadStatus( · 4 unmodified lines
84 pulse: true,42 pulse: true,
85 };43 };
86 }44 }
87 45 if (status === "failed") {
88 if (thread.session?.status === "starting") {
89 return {46 return {
90 kind: "connecting",47 kind: "connecting",
91 label: "Connecting",48 label: "Connecting",
apps/mobile/src/features/threads/use-project-actions.ts+10−9
@@ -58,15 +58,16 @@ export function useCreateProjectThread() { · 57 unmodified lines
58 58
59 const result = await startTurn({59 const result = await startTurn({
60 environmentId: input.project.environmentId,60 environmentId: input.project.environmentId,
61 input: buildProjectThreadStartTurnInput({61 input: {
62 projectId: input.project.id,62 commandId: CommandId.make(metadata.commandId),
63 projectCwd: input.project.workspaceRoot,63 creationSource: "mobile",
64 threadId: metadata.threadId,64 threadId,
65 commandId: metadata.commandId,65 message: {
66 messageId: metadata.messageId,66 messageId: MessageId.make(metadata.messageId),
67 createdAt: metadata.createdAt,67 role: "user",
68 text: initialMessageText,68 text: initialMessageText,
69 attachments: input.initialAttachments,69 attachments: input.initialAttachments,
70 },
70 modelSelection: input.modelSelection,71 modelSelection: input.modelSelection,
71 runtimeMode: input.runtimeMode,72 runtimeMode: input.runtimeMode,
72 interactionMode: input.interactionMode,73 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
4 4
5import { groupProjectsByRepository } from "./repositoryGroups";5import { groupProjectsByRepository } from "./repositoryGroups";
6import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";6import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
7import { makeThreadShellFixture } from "../test-fixtures";
7 8
8function makeProject(9function makeProject(
9 input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">,10 input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">,
@@ -23,24 +24,20 @@ function makeThread( · 13 unmodified lines
23 input: Partial<EnvironmentThreadShell> &24 input: Partial<EnvironmentThreadShell> &
24 Pick<EnvironmentThreadShell, "environmentId" | "id" | "projectId" | "title" | "modelSelection">,25 Pick<EnvironmentThreadShell, "environmentId" | "id" | "projectId" | "title" | "modelSelection">,
25): EnvironmentThreadShell {26): EnvironmentThreadShell {
26 return {27 return makeThreadShellFixture({
27 runtimeMode: "full-access",28 runtimeMode: "full-access",
28 interactionMode: "default",29 interactionMode: "default",
29 branch: null,30 branch: null,
30 worktreePath: null,31 worktreePath: null,
31 latestTurn: null,
32 createdAt: "2026-04-01T00:00:00.000Z",32 createdAt: "2026-04-01T00:00:00.000Z",
33 updatedAt: "2026-04-01T00:00:00.000Z",33 updatedAt: "2026-04-01T00:00:00.000Z",
34 archivedAt: null,34 archivedAt: null,
35 session: null,
36 latestUserMessageAt: null,35 latestUserMessageAt: null,
37 hasPendingApprovals: false,36 hasPendingApprovals: false,
38 hasPendingUserInput: false,37 hasPendingUserInput: false,
39 hasActionableProposedPlan: false,38 hasActionableProposedPlan: false,
40 ...input,39 ...input,
41 settledOverride: input.settledOverride ?? null,40 });
42 settledAt: input.settledAt ?? null,
43 };
44}41}
45 42
46describe("groupProjectsByRepository", () => {43describe("groupProjectsByRepository", () => {
apps/mobile/src/lib/scopedEntities.ts+2−2
@@ -1,4 +1,4 @@
1import { ApprovalRequestId, EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";1import { EnvironmentId, ProjectId, RuntimeRequestId, ThreadId } from "@t3tools/contracts";
2 2
3export function scopedProjectKey(environmentId: EnvironmentId, projectId: ProjectId): string {3export function scopedProjectKey(environmentId: EnvironmentId, projectId: ProjectId): string {
4 return `${environmentId}:${projectId}`;4 return `${environmentId}:${projectId}`;
@@ -10,7 +10,7 @@ export function scopedThreadKey(environmentId: EnvironmentId, threadId: ThreadId · 5 unmodified lines
10 10
11export function scopedRequestKey(11export function scopedRequestKey(
12 environmentId: EnvironmentId,12 environmentId: EnvironmentId,
13 requestId: ApprovalRequestId,13 requestId: RuntimeRequestId,
14): string {14): string {
15 return `${environmentId}:${requestId}`;15 return `${environmentId}:${requestId}`;
16}16}
apps/mobile/src/lib/threadActivity.test.ts+79−433
@@ -1,466 +1,112 @@
1import type { ThreadWorkEntry } from "@t3tools/client-runtime/state/shell";
2import { MessageId, RunId } from "@t3tools/contracts";
1import { describe, expect, it } from "vite-plus/test";3import { describe, expect, it } from "vite-plus/test";
2 4
3import {5import { makeThreadFixture } from "../test-fixtures";
4 EventId,6import { buildThreadFeed, deriveThreadFeedPresentation } from "./threadActivity";
5 MessageId,
6 ProjectId,
7 ProviderInstanceId,
8 ThreadId,
9 TurnId,
10 type OrchestrationThread,
11 type OrchestrationThreadActivity,
12} from "@t3tools/contracts";
13 7
14import {8const runId = RunId.make("run-1");
15 buildThreadFeed,
16 deriveThreadFeedPresentation,
17 type ThreadFeedActivity,
18 type ThreadFeedEntry,
19} from "./threadActivity";
20 9
21function makeActivity(10function message(role: "user" | "assistant", text: string, createdAt: string, id: string) {
22 input: Partial<OrchestrationThreadActivity> &
23 Pick<OrchestrationThreadActivity, "id" | "kind" | "summary" | "createdAt">,
24): OrchestrationThreadActivity {
25 return {11 return {
26 tone: "info",12 id: MessageId.make(id),
27 payload: {},13 role,
28 turnId: null,14 text,
29 ...input,15 attachments: [],
30 };16 runId: role === "assistant" ? runId : null,
17 streaming: false,
18 createdAt,
19 updatedAt: createdAt,
20 } as const;
31}21}
32 22
33function makeThread(23function commandEntry(overrides: Partial<ThreadWorkEntry> = {}): ThreadWorkEntry {
34 input: Partial<OrchestrationThread> & Pick<OrchestrationThread, "id" | "projectId" | "title">,24 const structuredPayload = {
35): OrchestrationThread {25 type: "command_execution",
26 input: "vp check",
27 output: "ok",
28 } as ThreadWorkEntry["structuredPayload"];
36 return {29 return {
37 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },30 id: "item-1",
38 runtimeMode: "full-access",31 createdAt: "2026-06-20T00:00:02.000Z",
39 interactionMode: "default",32 runId,
40 branch: null,33 label: "Ran command",
41 worktreePath: null,34 command: "vp check",
42 latestTurn: null,35 detail: "ok",
43 createdAt: "2026-04-01T00:00:00.000Z",36 tone: "tool",
44 updatedAt: "2026-04-01T00:00:00.000Z",37 itemType: "command_execution",
45 archivedAt: null,38 toolLifecycleStatus: "completed",
46 deletedAt: null,39 structuredPayload,
47 messages: [],40 ...overrides,
48 proposedPlans: [],
49 activities: [],
50 checkpoints: [],
51 session: null,
52 ...input,
53 settledOverride: input.settledOverride ?? null,
54 settledAt: input.settledAt ?? null,
55 };41 };
56}42}
57 43
58describe("buildThreadFeed", () => {44describe("buildThreadFeed", () => {
59 it("keeps historic work entries attributed to their turns", () => {45 it("orders V2 messages and work entries while retaining structured tool data", () => {
60 const thread = makeThread({46 const thread = makeThreadFixture({
61 id: ThreadId.make("thread-1"),47 messages: [
62 projectId: ProjectId.make("project-1"),48 message("user", "Run checks", "2026-06-20T00:00:01.000Z", "message-user"),
63 title: "Runtime warning thread",49 message("assistant", "Done", "2026-06-20T00:00:03.000Z", "message-assistant"),
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 }),
151 ],50 ],
51 workEntries: [commandEntry()],
152 });52 });
153 53
154 const feed = buildThreadFeed(thread);54 const feed = buildThreadFeed(thread);
155 const group = feed[0];55 expect(feed.map((entry) => entry.type)).toEqual(["message", "activity-group", "message"]);
156 56 const activity = feed.find((entry) => entry.type === "activity-group")?.activities[0];
157 expect(group).toMatchObject({57 expect(activity?.runId).toBe(runId);
158 type: "activity-group",58 expect(activity?.fullDetail).toContain('"input": "vp check"');
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");
230 });59 });
231 60
232 it("defers large tool output expansion until a work row is opened or copied", () => {61 it("folds settled V2 run work while keeping the terminal assistant message visible", () => {
233 let serializedToolOutputs = 0;62 const thread = makeThreadFixture({
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 },
293 messages: [63 messages: [
294 {64 message("user", "Run checks", "2026-06-20T00:00:01.000Z", "message-user"),
295 id: MessageId.make("assistant-commentary"),65 message("assistant", "Done", "2026-06-20T00:00:03.000Z", "message-assistant"),
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 }),
327 ],66 ],
67 workEntries: [commandEntry()],
328 });68 });
329
330 const feed = buildThreadFeed(thread);69 const feed = buildThreadFeed(thread);
331 const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set());70 const latestRun = {
332 expect(collapsed.map((entry) => entry.id)).toEqual(["turn-fold:turn-1", "assistant-final"]);71 runId,
333 expect(collapsed[0]).toMatchObject({72 status: "completed" as const,
334 type: "turn-fold",73 startedAt: "2026-06-20T00:00:01.000Z",
335 label: "Worked for 17s",74 completedAt: "2026-06-20T00:00:03.000Z",
336 expanded: false,75 };
337 });76
338 77 const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set());
339 const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId]));78 expect(collapsed.map((entry) => entry.type)).toEqual(["message", "run-fold", "message"]);
340 expect(expanded.map((entry) => entry.id)).toEqual([79
341 "turn-fold:turn-1",80 const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([runId]));
342 "assistant-commentary",81 expect(expanded.map((entry) => entry.type)).toEqual([
343 "tool-completed",82 "message",
344 "assistant-final",83 "run-fold",
84 "activity-group",
85 "message",
345 ]);86 ]);
346 });87 });
347 88
348 it("measures a steer-superseded turn from its user boundary through trailing work", () => {89 it("keeps an active run expanded and marks failed tools as failures", () => {
349 const firstTurnId = TurnId.make("turn-1");90 const thread = makeThreadFixture({
350 const secondTurnId = TurnId.make("turn-2");91 messages: [message("user", "Run checks", "2026-06-20T00:00:01.000Z", "message-user")],
351 const thread = makeThread({92 workEntries: [commandEntry({ tone: "error", toolLifecycleStatus: "failed" })],
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 ],
416 });93 });
417
418 const feed = buildThreadFeed(thread);94 const feed = buildThreadFeed(thread);
419 const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set());95 const presented = deriveThreadFeedPresentation(
420 expect(collapsed.find((entry) => entry.type === "turn-fold")).toMatchObject({96 feed,
421 turnId: firstTurnId,97 {
422 label: "Worked for 12s",98 runId,
423 });99 status: "running",
424 });100 startedAt: "2026-06-20T00:00:01.000Z",
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",
437 completedAt: null,101 completedAt: null,
438 assistantMessageId: null,
439 },102 },
440 activities: [103 new Set(),
441 makeActivity({104 );
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 });
457 105
458 const feed = buildThreadFeed(thread);106 expect(presented.some((entry) => entry.type === "run-fold")).toBe(false);
459 expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toEqual(feed);107 expect(presented.find((entry) => entry.type === "activity-group")?.activities[0]?.status).toBe(
460 expect(feed[0]).toMatchObject({108 "failure",
461 type: "activity-group",109 );
462 activities: [{ status: "failure" }],
463 });
464 });110 });
465 111
466 it("appends active work as a normal timeline row", () => {112 it("appends active work as a normal timeline row", () => {
apps/mobile/src/lib/threadActivity.ts+214−1186
@@ -1,29 +1,17 @@
1import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts";
2import type {1import type {
3 OrchestrationLatestTurn,2 EnvironmentThread,
4 OrchestrationThread,3 ThreadConversationMessage,
5 OrchestrationThreadActivity,4 ThreadPendingApproval,
6 ToolLifecycleItemType,5 ThreadPendingUserInput,
7 TurnId,6 ThreadRunSummary,
8 UserInputQuestion,7 ThreadUserInputQuestion,
9} from "@t3tools/contracts";8 ThreadWorkEntry,
9} from "@t3tools/client-runtime/state/shell";
10import type { RunId } from "@t3tools/contracts";
10import { formatDuration } from "@t3tools/shared/orchestrationTiming";11import { formatDuration } from "@t3tools/shared/orchestrationTiming";
11 12
12import * as Arr from "effect/Array";13export type PendingApproval = ThreadPendingApproval;
13import * as Order from "effect/Order";14export type PendingUserInput = ThreadPendingUserInput;
14
15export interface PendingApproval {
16 readonly requestId: ApprovalRequestId;
17 readonly requestKind: "command" | "file-read" | "file-change";
18 readonly createdAt: string;
19 readonly detail?: string;
20}
21
22export interface PendingUserInput {
23 readonly requestId: ApprovalRequestId;
24 readonly createdAt: string;
25 readonly questions: ReadonlyArray<UserInputQuestion>;
26}
27 15
28export interface PendingUserInputDraftAnswer {16export interface PendingUserInputDraftAnswer {
29 readonly selectedOptionLabel?: string;17 readonly selectedOptionLabel?: string;
@@ -33,7 +21,7 @@ export interface PendingUserInputDraftAnswer { · 3 unmodified lines
33export interface ThreadFeedActivity {21export interface ThreadFeedActivity {
34 readonly id: string;22 readonly id: string;
35 readonly createdAt: string;23 readonly createdAt: string;
36 readonly turnId: TurnId | null;24 readonly runId: RunId | null;
37 readonly summary: string;25 readonly summary: string;
38 readonly detail: string | null;26 readonly detail: string | null;
39 readonly canExpand: boolean;27 readonly canExpand: boolean;
@@ -56,44 +44,18 @@ export interface ThreadFeedActivity { · 16 unmodified lines
56 readonly status: "success" | "failure" | "neutral" | null;44 readonly status: "success" | "failure" | "neutral" | null;
57}45}
58 46
59const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
60
61type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped";
62
63interface 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
80interface DerivedWorkLogEntry extends WorkLogEntry {
81 activityKind: OrchestrationThreadActivity["kind"];
82 collapseKey?: string;
83}
84
85type RawThreadFeedEntry =47type RawThreadFeedEntry =
86 | {48 | {
87 readonly type: "message";49 readonly type: "message";
88 readonly id: string;50 readonly id: string;
89 readonly createdAt: string;51 readonly createdAt: string;
90 readonly message: OrchestrationThread["messages"][number];52 readonly message: ThreadConversationMessage;
91 }53 }
92 | {54 | {
93 readonly type: "activity";55 readonly type: "activity";
94 readonly id: string;56 readonly id: string;
95 readonly createdAt: string;57 readonly createdAt: string;
96 readonly turnId: TurnId | null;58 readonly runId: RunId | null;
97 readonly activity: ThreadFeedActivity;59 readonly activity: ThreadFeedActivity;
98 };60 };
99 61
@@ -108,119 +70,25 @@ export type ThreadFeedEntry = · 8 unmodified lines
108 readonly type: "activity-group";70 readonly type: "activity-group";
109 readonly id: string;71 readonly id: string;
110 readonly createdAt: string;72 readonly createdAt: string;
111 readonly turnId: TurnId | null;73 readonly runId: RunId | null;
112 readonly activities: ReadonlyArray<ThreadFeedActivity>;74 readonly activities: ReadonlyArray<ThreadFeedActivity>;
113 }75 }
114 | {76 | {
115 readonly type: "work-toggle";77 readonly type: "run-fold";
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";
126 readonly id: string;78 readonly id: string;
127 readonly createdAt: string;79 readonly createdAt: string;
128 readonly turnId: TurnId;80 readonly runId: RunId;
129 readonly label: string;81 readonly label: string;
130 readonly expanded: boolean;82 readonly expanded: boolean;
131 };83 };
132 84
133export type ThreadFeedLatestTurn = Pick<85export type ThreadFeedLatestRun = Pick<
134 OrchestrationLatestTurn,86 ThreadRunSummary,
135 "turnId" | "state" | "startedAt" | "completedAt"87 "runId" | "status" | "startedAt" | "completedAt"
136>;88>;
137 89
138function 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
153function 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
167function parseApprovalRequestId(value: unknown): ApprovalRequestId | null {
168 return typeof value === "string" && value.length > 0 ? ApprovalRequestId.make(value) : null;
169}
170
171function 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
220function normalizeDraftAnswer(value: string | undefined): string | null {90function normalizeDraftAnswer(value: string | undefined): string | null {
221 if (typeof value !== "string") {91 if (typeof value !== "string") return null;
222 return null;
223 }
224 const trimmed = value.trim();92 const trimmed = value.trim();
225 return trimmed.length > 0 ? trimmed : null;93 return trimmed.length > 0 ? trimmed : null;
226}94}
@@ -228,882 +96,247 @@ function normalizeDraftAnswer(value: string | undefined): string | null { · 1 unmodified line
228function resolvePendingUserInputAnswer(96function resolvePendingUserInputAnswer(
229 draft: PendingUserInputDraftAnswer | undefined,97 draft: PendingUserInputDraftAnswer | undefined,
230): string | null {98): string | null {
231 const customAnswer = normalizeDraftAnswer(draft?.customAnswer);
232 if (customAnswer) {
233 return customAnswer;
234 }
235 return normalizeDraftAnswer(draft?.selectedOptionLabel);
236}
237
238function 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
254function 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
266function 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
351function 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
366function 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
382function 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
412function 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
423function 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
436function normalizeCompactToolLabel(value: string): string {
437 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
438}
439
440function 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
453function toolDetailTextLooksLikeFailure(text: string): boolean {
454 const normalized = text.toLowerCase();
455 return (99 return (
456 normalized.includes("file not found") ||100 normalizeDraftAnswer(draft?.customAnswer) ?? normalizeDraftAnswer(draft?.selectedOptionLabel)
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)
468 );101 );
469}102}
470 103
471function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean {104function capitalizePhrase(value: string): string {
472 if (entry.tone === "error") {105 const trimmed = value.trim();
473 return true;106 return trimmed.length === 0 ? value : `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`;
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"));
482}107}
483 108
484function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean {109function workEntryIsToolLike(entry: ThreadWorkEntry): boolean {
485 if (!workLogEntryIsToolLike(entry) || workEntryIndicatesToolFailure(entry)) {
486 return false;
487 }
488 if (entry.tone === "thinking") {
489 return false;
490 }
491 return (110 return (
492 entry.toolLifecycleStatus !== "inProgress" &&111 entry.tone === "tool" ||
493 entry.toolLifecycleStatus !== "stopped" &&112 entry.tone === "thinking" ||
494 entry.toolLifecycleStatus !== "failed" &&113 entry.tone === "error" ||
495 entry.toolLifecycleStatus !== "declined"114 entry.command !== undefined ||
115 entry.requestKind !== undefined
496 );116 );
497}117}
498 118
499function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] {119function workEntryStatus(entry: ThreadWorkEntry): ThreadFeedActivity["status"] {
500 if (!workLogEntryIsToolLike(entry)) {120 if (!workEntryIsToolLike(entry)) return null;
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
512function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] {
513 if (121 if (
514 entry.activityKind === "user-input.requested" ||122 entry.tone === "error" ||
515 entry.activityKind === "user-input.resolved"123 entry.toolLifecycleStatus === "failed" ||
124 entry.toolLifecycleStatus === "declined"
516 ) {125 ) {
517 return "message";126 return "failure";
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";
530 }127 }
531 if (entry.tone === "error") return "alert";128 return entry.toolLifecycleStatus === "completed" ? "success" : "neutral";
532 if (entry.tone === "thinking") return "agent";
533 if (entry.tone === "info") return "check";
534 return "zap";
535}129}
536 130
537function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null {131function workEntryIcon(entry: ThreadWorkEntry): ThreadFeedActivity["icon"] {
538 const blocks: string[] = [];132 switch (entry.itemType) {
539 const appendUniqueBlock = (value: string | null | undefined) => {133 case "reasoning":
540 const trimmed = value?.trim();134 return "agent";
541 if (trimmed && !blocks.includes(trimmed)) {135 case "command_execution":
542 blocks.push(trimmed);136 return "command";
543 }137 case "file_change":
544 };138 return "edit";
545 139 case "file_search":
546 if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) {140 return "eye";
547 appendUniqueBlock(`MCP call\n${JSON.stringify(entry.toolData, null, 2)}`);141 case "web_search":
548 }142 return "globe";
549 appendUniqueBlock(entry.rawCommand ?? entry.command);143 case "approval_request":
550 appendUniqueBlock(entry.detail);144 case "user_input_request":
551 if ((entry.changedFiles?.length ?? 0) > 0) {145 return "message";
552 appendUniqueBlock(entry.changedFiles!.join("\n"));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";
553 }160 }
554
555 return blocks.length > 0 ? blocks.join("\n\n") : null;
556}
557
558function 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
567function 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 };
577}161}
578 162
579function workEntryPreview(163function workEntryPreview(entry: ThreadWorkEntry): string | null {
580 workEntry: Pick<WorkLogEntry, "detail" | "command" | "changedFiles">,164 if (entry.command) return entry.command;
581): string | null {165 if (entry.detail) return entry.detail;
582 if (workEntry.command) return workEntry.command;166 const firstPath = entry.changedFiles?.[0];
583 if (workEntry.detail) return workEntry.detail;
584 if ((workEntry.changedFiles?.length ?? 0) === 0) return null;
585 const [firstPath] = workEntry.changedFiles ?? [];
586 if (!firstPath) return null;167 if (!firstPath) return null;
587 return workEntry.changedFiles!.length === 1168 return entry.changedFiles?.length === 1
588 ? firstPath169 ? firstPath
589 : `${firstPath} +${workEntry.changedFiles!.length - 1} more`;170 : `${firstPath} +${(entry.changedFiles?.length ?? 1) - 1} more`;
590}
591
592function 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
600function workEntryHeading(workEntry: WorkLogEntry): string {
601 if (!workEntry.toolTitle) {
602 return capitalizePhrase(normalizeCompactToolLabel(workEntry.label));
603 }
604 return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle));
605}
606
607function asRecord(value: unknown): Record<string, unknown> | null {
608 return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
609}
610
611function 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
619function 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
631function 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
642function 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
674const 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
689function findShellWrapperSpec(shell: string) {
690 return SHELL_WRAPPER_SPECS.find((spec) =>
691 (spec.executables as ReadonlyArray<string>).includes(shell),
692 );
693}171}
694 172
695function unwrapCommandRemainder(value: string, wrapperFlagPattern: RegExp): string | null {173function buildWorkEntryExpandedBody(entry: ThreadWorkEntry): string | null {
696 const match = wrapperFlagPattern.exec(value);174 const blocks: string[] = [];
697 if (!match) {175 const append = (value: string | null | undefined) => {
698 return null;176 const trimmed = value?.trim();
699 }177 if (trimmed && !blocks.includes(trimmed)) blocks.push(trimmed);
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
710function 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
729function formatCommandArrayPart(value: string): string {
730 return /[\s"'`]/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value;
731}
732
733function 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
754function normalizeCommandValue(value: unknown): string | null {
755 const formatted = formatCommandValue(value);
756 return formatted ? unwrapKnownShellCommandWrapper(formatted) : null;
757}
758
759function 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
767function 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,
799 };178 };
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");
800}184}
801 185
802function extractToolTitle(payload: Record<string, unknown> | null): string | null {186function toFeedActivity(entry: ThreadWorkEntry): ThreadFeedActivity {
803 return asTrimmedString(payload?.title);187 const summary = capitalizePhrase(entry.toolTitle ?? entry.label);
804}188 const detail = workEntryPreview(entry);
805 189 const fullDetail = buildWorkEntryExpandedBody(entry);
806function 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
822function 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() ?? "";
837 return {190 return {
838 output: normalizedOutput.length > 0 ? normalizedOutput : null,191 id: entry.id,
839 ...(Number.isInteger(exitCode) ? { exitCode } : {}),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),
840 };206 };
841}207}
842 208
843function extractWorkLogItemType(209function byCreatedAt<A extends { readonly createdAt: string }>(left: A, right: A): number {
844 payload: Record<string, unknown> | null,210 return left.createdAt.localeCompare(right.createdAt);
845): WorkLogEntry["itemType"] | undefined {
846 if (typeof payload?.itemType === "string" && isToolLifecycleItemType(payload.itemType)) {
847 return payload.itemType;
848 }
849 return undefined;
850}
851
852function 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
865function 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);
872}211}
873 212
874function 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
922function 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
929function 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
942const 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
949function isEmptyMessage(entry: RawThreadFeedEntry): boolean {213function isEmptyMessage(entry: RawThreadFeedEntry): boolean {
950 if (entry.type !== "message") {214 return (
951 return false;215 entry.type === "message" &&
952 }216 entry.message.text.trim().length === 0 &&
953 const hasText = entry.message.text.trim().length > 0;217 (entry.message.attachments ?? []).length === 0
954 const hasAttachments = (entry.message.attachments ?? []).length > 0;218 );
955 return !hasText && !hasAttachments;
956}219}
957 220
958function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): ThreadFeedEntry[] {221function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): ThreadFeedEntry[] {
959 const grouped: ThreadFeedEntry[] = [];222 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
966 for (const entry of entries) {223 for (const entry of entries) {
967 // Skip empty messages so they don't break activity grouping.224 if (isEmptyMessage(entry)) continue;
968 if (isEmptyMessage(entry)) {
969 continue;
970 }
971
972 if (entry.type !== "activity") {225 if (entry.type !== "activity") {
973 grouped.push(entry);226 grouped.push(entry);
974 openGroupActivities = null;227 openGroupActivities = null;
975 continue;228 continue;
976 }229 }
977 230 const previous = grouped.at(-1);
978 if (openGroupActivities !== null && openGroupTurnId === entry.turnId) {231 if (previous?.type === "activity-group" && previous.runId === entry.runId) {
979 openGroupActivities.push(entry.activity);232 grouped[grouped.length - 1] = {
233 ...previous,
234 activities: [...previous.activities, entry.activity],
235 };
980 continue;236 continue;
981 }237 }
982
983 openGroupActivities = [entry.activity];
984 openGroupTurnId = entry.turnId;
985 grouped.push({238 grouped.push({
986 type: "activity-group",239 type: "activity-group",
987 id: entry.id,240 id: entry.id,
988 createdAt: entry.createdAt,241 createdAt: entry.createdAt,
989 turnId: entry.turnId,242 runId: entry.runId,
990 activities: openGroupActivities,243 activities: [entry.activity],
991 });244 });
992 }245 }
993
994 return grouped;246 return grouped;
995}247}
996 248
997function computeElapsedMs(startIso: string, endIso: string): number | null {249function computeElapsedMs(startIso: string, endIso: string): number | null {
998 const start = Date.parse(startIso);250 const start = Date.parse(startIso);
999 const end = Date.parse(endIso);251 const end = Date.parse(endIso);
1000 if (!Number.isFinite(start) || !Number.isFinite(end)) {252 return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : null;
1001 return null;
1002 }
1003 return Math.max(0, end - start);
1004}253}
1005 254
1006function maxIsoTimestamp(a: string | null, b: string | null): string | null {255function maxIsoTimestamp(left: string | null, right: string | null): string | null {
1007 if (a === null) return b;256 if (left === null) return right;
1008 if (b === null) return a;257 if (right === null) return left;
1009 const aMs = Date.parse(a);258 return Date.parse(right) > Date.parse(left) ? right : left;
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;
1014}259}
1015 260
1016function deriveUnsettledTurnId(latestTurn: ThreadFeedLatestTurn | null): TurnId | null {261function unsettledRunId(latestRun: ThreadFeedLatestRun | null): RunId | null {
1017 if (!latestTurn) {262 if (!latestRun) return null;
1018 return null;263 return latestRun.completedAt === null ||
1019 }264 latestRun.status === "starting" ||
1020 const settled = latestTurn.completedAt !== null && latestTurn.state !== "running";265 latestRun.status === "running" ||
1021 return settled ? null : latestTurn.turnId;266 latestRun.status === "waiting"
267 ? latestRun.runId
268 : null;
1022}269}
1023 270
1024interface ThreadFeedTurnFold {271interface ThreadFeedRunFold {
1025 readonly turnId: TurnId;272 readonly runId: RunId;
1026 readonly createdAt: string;273 readonly createdAt: string;
1027 readonly hiddenEntryIds: ReadonlySet<string>;274 readonly hiddenEntryIds: ReadonlySet<string>;
1028 readonly label: string;275 readonly label: string;
1029}276}
1030 277
1031function deriveThreadFeedTurnFolds(278function deriveThreadFeedRunFolds(
1032 feed: ReadonlyArray<ThreadFeedEntry>,279 feed: ReadonlyArray<ThreadFeedEntry>,
1033 latestTurn: ThreadFeedLatestTurn | null,280 latestRun: ThreadFeedLatestRun | null,
1034): ReadonlyMap<string, ThreadFeedTurnFold> {281): ReadonlyMap<string, ThreadFeedRunFold> {
1035 const terminalAssistantMessageIdByTurn = new Map<TurnId, string>();282 const terminalAssistantMessageIdByRun = new Map<RunId, string>();
1036 for (const entry of feed) {283 for (const entry of feed) {
1037 if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) {284 if (entry.type === "message" && entry.message.role === "assistant" && entry.message.runId) {
1038 terminalAssistantMessageIdByTurn.set(entry.message.turnId, entry.id);285 terminalAssistantMessageIdByRun.set(entry.message.runId, entry.id);
1039 }286 }
1040 }287 }
1041 288
1042 interface TurnGroup {289 const groupsByRunId = new Map<
1043 readonly entries: ThreadFeedEntry[];290 RunId,
1044 readonly startBoundary: string | null;291 { entries: ThreadFeedEntry[]; startBoundary: string | null }
1045 }292 >();
1046 const groupsByTurnId = new Map<TurnId, TurnGroup>();
1047 let pendingUserBoundary: string | null = null;293 let pendingUserBoundary: string | null = null;
1048 for (const entry of feed) {294 for (const entry of feed) {
1049 if (entry.type === "message" && entry.message.role === "user") {295 if (entry.type === "message" && entry.message.role === "user") {
1050 pendingUserBoundary = entry.message.createdAt;296 pendingUserBoundary = entry.message.createdAt;
1051 continue;297 continue;
1052 }298 }
1053 const turnId =299 const runId =
1054 entry.type === "message" && entry.message.role === "assistant"300 entry.type === "message" && entry.message.role === "assistant"
1055 ? entry.message.turnId301 ? entry.message.runId
1056 : entry.type === "activity-group"302 : entry.type === "activity-group"
1057 ? entry.turnId303 ? entry.runId
1058 : null;304 : null;
1059 if (!turnId) {305 if (!runId) continue;
1060 continue;306 let group = groupsByRunId.get(runId);
1061 }
1062 let group = groupsByTurnId.get(turnId);
1063 if (!group) {307 if (!group) {
1064 group = {308 group = { entries: [], startBoundary: pendingUserBoundary };
1065 entries: [],
1066 startBoundary: pendingUserBoundary,
1067 };
1068 pendingUserBoundary = null;309 pendingUserBoundary = null;
1069 groupsByTurnId.set(turnId, group);310 groupsByRunId.set(runId, group);
1070 }311 }
1071 group.entries.push(entry);312 group.entries.push(entry);
1072 }313 }
1073 314
1074 const unsettledTurnId = deriveUnsettledTurnId(latestTurn);315 const activeRunId = unsettledRunId(latestRun);
1075 const foldsByAnchorId = new Map<string, ThreadFeedTurnFold>();316 const foldsByAnchorId = new Map<string, ThreadFeedRunFold>();
1076 for (const [turnId, group] of groupsByTurnId) {317 for (const [runId, group] of groupsByRunId) {
1077 const { entries } = group;318 if (
1078 if (turnId === unsettledTurnId) {319 runId === activeRunId ||
1079 continue;320 group.entries.some((entry) => entry.type === "message" && entry.message.streaming)
1080 }321 ) {
1081 if (entries.some((entry) => entry.type === "message" && entry.message.streaming)) {
1082 continue;322 continue;
1083 }323 }
1084 324 const terminalAssistantId = terminalAssistantMessageIdByRun.get(runId);
1085 const terminalAssistantMessageId = terminalAssistantMessageIdByTurn.get(turnId);
1086 const hiddenEntryIds = new Set(325 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),
1088 );327 );
1089 if (hiddenEntryIds.size === 0) {328 const firstEntry = group.entries[0];
1090 continue;329 const lastEntry = group.entries.at(-1);
1091 }330 if (hiddenEntryIds.size === 0 || !firstEntry || !lastEntry) continue;
1092 331 const terminalEntry = terminalAssistantId
1093 const firstEntry = entries[0];332 ? group.entries.find((entry) => entry.id === terminalAssistantId)
1094 const lastEntry = entries.at(-1);
1095 if (!firstEntry || !lastEntry) {
1096 continue;
1097 }
1098 const terminalEntry = terminalAssistantMessageId
1099 ? entries.find((entry) => entry.id === terminalAssistantMessageId)
1100 : null;333 : null;
1101 const latestTurnMatches = latestTurn?.turnId === turnId;334 const latestRunMatches = latestRun?.runId === runId;
1102 const lastEntryEnd =335 const lastEntryEnd =
1103 lastEntry.type === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt;336 lastEntry.type === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt;
1104 const elapsedMs =337 const elapsedMs =
1105 latestTurnMatches && latestTurn.startedAt && latestTurn.completedAt338 latestRunMatches && latestRun.startedAt && latestRun.completedAt
1106 ? computeElapsedMs(latestTurn.startedAt, latestTurn.completedAt)339 ? computeElapsedMs(latestRun.startedAt, latestRun.completedAt)
1107 : computeElapsedMs(340 : computeElapsedMs(
1108 group.startBoundary ?? firstEntry.createdAt,341 group.startBoundary ?? firstEntry.createdAt,
1109 maxIsoTimestamp(342 maxIsoTimestamp(
@@ -1112,20 +345,19 @@ function deriveThreadFeedTurnFolds( · 2 unmodified lines
1112 ) ?? lastEntryEnd,345 ) ?? lastEntryEnd,
1113 );346 );
1114 const duration = elapsedMs === null ? null : formatDuration(elapsedMs);347 const duration = elapsedMs === null ? null : formatDuration(elapsedMs);
1115 const interrupted = latestTurnMatches && latestTurn.state === "interrupted";348 const interrupted =
1116 const label = interrupted349 latestRunMatches && (latestRun.status === "interrupted" || latestRun.status === "cancelled");
1117 ? duration
1118 ? `You stopped after ${duration}`
1119 : "You stopped this response"
1120 : duration
1121 ? `Worked for ${duration}`
1122 : "Worked";
1123
1124 foldsByAnchorId.set(firstEntry.id, {350 foldsByAnchorId.set(firstEntry.id, {
1125 turnId,351 runId,
1126 createdAt: firstEntry.createdAt,352 createdAt: firstEntry.createdAt,
1127 hiddenEntryIds,353 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",
1129 });361 });
1130 }362 }
1131 return foldsByAnchorId;363 return foldsByAnchorId;
@@ -1133,41 +365,31 @@ function deriveThreadFeedTurnFolds( · 1 unmodified line
1133 365
1134export function deriveThreadFeedPresentation(366export function deriveThreadFeedPresentation(
1135 feed: ReadonlyArray<ThreadFeedEntry>,367 feed: ReadonlyArray<ThreadFeedEntry>,
1136 latestTurn: ThreadFeedLatestTurn | null,368 latestRun: ThreadFeedLatestRun | null,
1137 expandedTurnIds: ReadonlySet<TurnId>,369 expandedRunIds: ReadonlySet<RunId>,
1138 expandedWorkGroupIds: ReadonlySet<string> = new Set(),
1139 activeWorkStartedAt: string | null = null,
1140): ThreadFeedEntry[] {370): ThreadFeedEntry[] {
1141 const sourceFeed = feed.filter(371 const sourceFeed = feed.filter((entry) => entry.type !== "run-fold");
1142 (entry) =>372 const foldsByAnchorId = deriveThreadFeedRunFolds(sourceFeed, latestRun);
1143 entry.type !== "turn-fold" && entry.type !== "work-toggle" && entry.type !== "working",
1144 );
1145 const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn);
1146 const collapsedEntryIds = new Set<string>();373 const collapsedEntryIds = new Set<string>();
1147 for (const fold of foldsByAnchorId.values()) {374 for (const fold of foldsByAnchorId.values()) {
1148 if (!expandedTurnIds.has(fold.turnId)) {375 if (!expandedRunIds.has(fold.runId)) {
1149 for (const entryId of fold.hiddenEntryIds) {376 for (const entryId of fold.hiddenEntryIds) collapsedEntryIds.add(entryId);
1150 collapsedEntryIds.add(entryId);
1151 }
1152 }377 }
1153 }378 }
1154
1155 const result: ThreadFeedEntry[] = [];379 const result: ThreadFeedEntry[] = [];
1156 for (const entry of sourceFeed) {380 for (const entry of sourceFeed) {
1157 const fold = foldsByAnchorId.get(entry.id);381 const fold = foldsByAnchorId.get(entry.id);
1158 if (fold) {382 if (fold) {
1159 result.push({383 result.push({
1160 type: "turn-fold",384 type: "run-fold",
1161 id: `turn-fold:${fold.turnId}`,385 id: `run-fold:${fold.runId}`,
1162 createdAt: fold.createdAt,386 createdAt: fold.createdAt,
1163 turnId: fold.turnId,387 runId: fold.runId,
1164 label: fold.label,388 label: fold.label,
1165 expanded: expandedTurnIds.has(fold.turnId),389 expanded: expandedRunIds.has(fold.runId),
1166 });390 });
1167 }391 }
1168 if (!collapsedEntryIds.has(entry.id)) {392 if (!collapsedEntryIds.has(entry.id)) result.push(entry);
1169 appendPresentedFeedEntry(result, entry, expandedWorkGroupIds);
1170 }
1171 }393 }
1172 if (activeWorkStartedAt !== null) {394 if (activeWorkStartedAt !== null) {
1173 result.push({395 result.push({
@@ -1179,248 +401,54 @@ export function deriveThreadFeedPresentation( · 5 unmodified lines
1179 return result;401 return result;
1180}402}
1181 403
1182function 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 */
1238export function sortThreadActivities(
1239 activities: ReadonlyArray<OrchestrationThreadActivity>,
1240): ReadonlyArray<OrchestrationThreadActivity> {
1241 return Arr.sort(activities, activityOrder);
1242}
1243
1244export 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
1290export 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
1333export function setPendingUserInputCustomAnswer(404export function setPendingUserInputCustomAnswer(
1334 draft: PendingUserInputDraftAnswer | undefined,405 draft: PendingUserInputDraftAnswer | undefined,
1335 customAnswer: string,406 customAnswer: string,
1336): PendingUserInputDraftAnswer {407): PendingUserInputDraftAnswer {
1337 const selectedOptionLabel =408 const selectedOptionLabel =
1338 customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel;409 customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel;
1339 return {410 return { customAnswer, ...(selectedOptionLabel ? { selectedOptionLabel } : {}) };
1340 customAnswer,
1341 ...(selectedOptionLabel ? { selectedOptionLabel } : {}),
1342 };
1343}411}
1344 412
1345export function buildPendingUserInputAnswers(413export function buildPendingUserInputAnswers(
1346 questions: ReadonlyArray<UserInputQuestion>,414 questions: ReadonlyArray<ThreadUserInputQuestion>,
1347 draftAnswers: Record<string, PendingUserInputDraftAnswer>,415 draftAnswers: Record<string, PendingUserInputDraftAnswer>,
1348): Record<string, string> | null {416): Record<string, string> | null {
1349 const answers: Record<string, string> = {};417 const answers: Record<string, string> = {};
1350
1351 for (const question of questions) {418 for (const question of questions) {
1352 const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]);419 const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]);
1353 if (!answer) {420 if (!answer) return null;
1354 return null;
1355 }
1356 answers[question.id] = answer;421 answers[question.id] = answer;
1357 }422 }
1358
1359 return answers;423 return answers;
1360}424}
1361 425
1362export function buildThreadFeed(426export function buildThreadFeed(
1363 thread: OrchestrationThread,427 thread: EnvironmentThread,
1364 options?: {428 options?: { readonly loadedMessages?: ReadonlyArray<ThreadConversationMessage> },
1365 readonly loadedMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>;
1366 },
1367): ThreadFeedEntry[] {429): ThreadFeedEntry[] {
1368 const loadedMessages = options?.loadedMessages ?? thread.messages;430 const loadedMessages = options?.loadedMessages ?? thread.messages;
1369 const oldestLoadedMessageCreatedAt =431 const oldestLoadedMessageCreatedAt =
1370 options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null;432 options?.loadedMessages === undefined ? null : (loadedMessages[0]?.createdAt ?? null);
1371 const workLogEntries = deriveWorkLogEntries(thread.activities);433 const entries: RawThreadFeedEntry[] = [
1372 const entries = Arr.sortWith(434 ...loadedMessages.map((message) => ({
1373 [435 type: "message" as const,
1374 ...loadedMessages.map<RawThreadFeedEntry>((message) => ({436 id: message.id,
1375 type: "message",437 createdAt: message.createdAt,
1376 id: message.id,438 message,
1377 createdAt: message.createdAt,439 })),
1378 message,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),
1379 })),451 })),
1380 ...workLogEntries452 ];
1381 .filter((entry) => {453 return groupAdjacentActivities(entries.toSorted(byCreatedAt));
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);
1426}454}
apps/mobile/src/state/queries.ts+3−8
@@ -1,10 +1,5 @@
1import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts";1import type { EnvironmentThread } from "@t3tools/client-runtime/state/shell";
2import {2import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
3 createThreadSearchResultsAtomFamily,
4 makeThreadSearchKey,
5 type EnvironmentThreadSearchMatch,
6} from "@t3tools/client-runtime/state/thread-search";
7import { useAtomValue } from "@effect/atom-react";
8import * as Option from "effect/Option";3import * as Option from "effect/Option";
9import { Atom } from "effect/unstable/reactivity";4import { Atom } from "effect/unstable/reactivity";
10import { useEffect, useMemo, useState } from "react";5import { useEffect, useMemo, useState } from "react";
@@ -40,7 +35,7 @@ const threadSearchResultsAtom = createThreadSearchResultsAtomFamily({ · 29 unmodified lines
40});35});
41 36
42export interface ThreadDetailView {37export interface ThreadDetailView {
43 readonly data: OrchestrationThread | null;38 readonly data: EnvironmentThread | null;
44 readonly error: string | null;39 readonly error: string | null;
45 readonly isPending: boolean;40 readonly isPending: boolean;
46 readonly isDeleted: boolean;41 readonly isDeleted: boolean;
apps/mobile/src/state/threads.ts+10−2
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
7 type EnvironmentThreadState,7 type EnvironmentThreadState,
8 createThreadEnvironmentAtoms,8 createThreadEnvironmentAtoms,
9} from "@t3tools/client-runtime/state/threads";9} from "@t3tools/client-runtime/state/threads";
10import { presentThread, type EnvironmentThread } from "@t3tools/client-runtime/state/shell";
10import type { EnvironmentId, ThreadId } from "@t3tools/contracts";11import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
11import * as Option from "effect/Option";12import * as Option from "effect/Option";
12import { AsyncResult, Atom } from "effect/unstable/reactivity";13import { AsyncResult, Atom } from "effect/unstable/reactivity";
@@ -32,14 +33,21 @@ const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_ · 19 unmodified lines
32export function useEnvironmentThread(33export function useEnvironmentThread(
33 environmentId: EnvironmentId | null,34 environmentId: EnvironmentId | null,
34 threadId: ThreadId | null,35 threadId: ThreadId | null,
35): EnvironmentThreadState {36): Omit<EnvironmentThreadState, "data"> & { readonly data: Option.Option<EnvironmentThread> } {
36 const result = useAtomValue(37 const result = useAtomValue(
37 environmentId !== null && threadId !== null38 environmentId !== null && threadId !== null
38 ? environmentThreads.stateAtom(environmentId, threadId)39 ? environmentThreads.stateAtom(environmentId, threadId)
39 : EMPTY_THREAD_STATE_ATOM,40 : EMPTY_THREAD_STATE_ATOM,
40 );41 );
41 return Option.getOrElse(42 const state = Option.getOrElse(
42 AsyncResult.value(result),43 AsyncResult.value(result),
43 () => EMPTY_ENVIRONMENT_THREAD_STATE,44 () => EMPTY_ENVIRONMENT_THREAD_STATE,
44 ) as EnvironmentThreadState;45 ) as EnvironmentThreadState;
46 return {
47 ...state,
48 data:
49 environmentId === null
50 ? Option.none()
51 : Option.map(state.data, (projection) => presentThread(environmentId, projection)),
52 };
45}53}
apps/mobile/src/state/use-selected-thread-requests.ts+23−17
@@ -1,15 +1,13 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { useCallback, useMemo, useState } from "react";2import { useCallback, useMemo, useState } from "react";
3 3
4import { ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts";4import { type ProviderApprovalDecision, type RuntimeRequestId } from "@t3tools/contracts";
5import { Atom } from "effect/unstable/reactivity";5import { Atom } from "effect/unstable/reactivity";
6 6
7import { threadEnvironment } from "../state/threads";7import { threadEnvironment } from "../state/threads";
8import { scopedRequestKey } from "../lib/scopedEntities";8import { scopedRequestKey } from "../lib/scopedEntities";
9import {9import {
10 buildPendingUserInputAnswers,10 buildPendingUserInputAnswers,
11 derivePendingApprovals,
12 derivePendingUserInputs,
13 setPendingUserInputCustomAnswer,11 setPendingUserInputCustomAnswer,
14 sortThreadActivities,12 sortThreadActivities,
15 type PendingUserInputDraftAnswer,13 type PendingUserInputDraftAnswer,
@@ -66,14 +64,11 @@ export function useSelectedThreadRequests() { · 50 unmodified lines
66 const { selectedThread: selectedThreadShell } = useThreadSelection();64 const { selectedThread: selectedThreadShell } = useThreadSelection();
67 const selectedThread = useSelectedThreadDetail();65 const selectedThread = useSelectedThreadDetail();
68 const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom);66 const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom);
69 const [respondingApprovalId, setRespondingApprovalId] = useState<ApprovalRequestId | null>(null);67 const [respondingApprovalId, setRespondingApprovalId] = useState<RuntimeRequestId | null>(null);
70 const [respondingUserInputId, setRespondingUserInputId] = useState<ApprovalRequestId | null>(68 const [respondingUserInputId, setRespondingUserInputId] = useState<RuntimeRequestId | null>(null);
71 null,
72 );
73 69
74 // Sort once; both derivations expect the same lifecycle ordering.70 const activePendingApprovals = useMemo(
75 const sortedActivities = useMemo(71 () => selectedThread?.pendingApprovals ?? [],
76 () => (selectedThread ? sortThreadActivities(selectedThread.activities) : []),
77 [selectedThread],72 [selectedThread],
78 );73 );
79 const activePendingApprovals = useMemo(74 const activePendingApprovals = useMemo(
@@ -82,8 +77,8 @@ export function useSelectedThreadRequests() { · 2 unmodified lines
82 );77 );
83 const activePendingApproval = activePendingApprovals[0] ?? null;78 const activePendingApproval = activePendingApprovals[0] ?? null;
84 const activePendingUserInputs = useMemo(79 const activePendingUserInputs = useMemo(
85 () => derivePendingUserInputs(sortedActivities),80 () => selectedThread?.pendingUserInputs ?? [],
86 [sortedActivities],81 [selectedThread],
87 );82 );
88 const activePendingUserInput = activePendingUserInputs[0] ?? null;83 const activePendingUserInput = activePendingUserInputs[0] ?? null;
89 const activePendingUserInputDrafts =84 const activePendingUserInputDrafts =
@@ -97,7 +92,7 @@ export function useSelectedThreadRequests() { · 7 unmodified lines
97 : null;92 : null;
98 93
99 const onSelectUserInputOption = useCallback(94 const onSelectUserInputOption = useCallback(
100 (requestId: ApprovalRequestId, questionId: string, label: string) => {95 (requestId: RuntimeRequestId, questionId: string, label: string) => {
101 if (!selectedThreadShell) {96 if (!selectedThreadShell) {
102 return;97 return;
103 }98 }
@@ -109,7 +104,7 @@ export function useSelectedThreadRequests() { · 5 unmodified lines
109 );104 );
110 105
111 const onChangeUserInputCustomAnswer = useCallback(106 const onChangeUserInputCustomAnswer = useCallback(
112 (requestId: ApprovalRequestId, questionId: string, customAnswer: string) => {107 (requestId: RuntimeRequestId, questionId: string, customAnswer: string) => {
113 if (!selectedThreadShell) {108 if (!selectedThreadShell) {
114 return;109 return;
115 }110 }
@@ -121,10 +116,16 @@ export function useSelectedThreadRequests() { · 5 unmodified lines
121 );116 );
122 117
123 const onRespondToApproval = useCallback(118 const onRespondToApproval = useCallback(
124 async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => {119 async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => {
125 if (!selectedThreadShell) {120 if (!selectedThreadShell) {
126 return;121 return;
127 }122 }
123 if (
124 activePendingApprovals.find((approval) => approval.requestId === requestId)
125 ?.responseCapability !== "live"
126 ) {
127 return;
128 }
128 129
129 setRespondingApprovalId(requestId);130 setRespondingApprovalId(requestId);
130 const result = await respondToApproval({131 const result = await respondToApproval({
@@ -138,11 +139,16 @@ export function useSelectedThreadRequests() { · 7 unmodified lines
138 setRespondingApprovalId((current) => (current === requestId ? null : current));139 setRespondingApprovalId((current) => (current === requestId ? null : current));
139 return result;140 return result;
140 },141 },
141 [respondToApproval, selectedThreadShell],142 [activePendingApprovals, respondToApproval, selectedThreadShell],
142 );143 );
143 144
144 const onSubmitUserInput = useCallback(async () => {145 const onSubmitUserInput = useCallback(async () => {
145 if (!selectedThreadShell || !activePendingUserInput || !activePendingUserInputAnswers) {146 if (
147 !selectedThreadShell ||
148 !activePendingUserInput ||
149 activePendingUserInput.responseCapability !== "live" ||
150 !activePendingUserInputAnswers
151 ) {
146 return;152 return;
147 }153 }
148 154
apps/mobile/src/state/use-thread-composer-state.ts+6−7
@@ -1,4 +1,5 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell";
2import { useCallback, useEffect, useMemo } from "react";3import { useCallback, useEffect, useMemo } from "react";
3 4
4import {5import {
@@ -106,13 +107,13 @@ export function useThreadComposerState() { · 101 unmodified lines
106 107
107 const selectedThreadSessionActivity = useMemo(() => {108 const selectedThreadSessionActivity = useMemo(() => {
108 const selectedThread = selectedThreadDetail ?? selectedThreadShell;109 const selectedThread = selectedThreadDetail ?? selectedThreadShell;
109 if (!selectedThread?.session) {110 if (!selectedThread?.runtime) {
110 return null;111 return null;
111 }112 }
112 113
113 return {114 return {
114 orchestrationStatus: selectedThread.session.status,115 orchestrationStatus: selectedThread.runtime.status,
115 activeTurnId: selectedThread.session.activeTurnId ?? undefined,116 activeRunId: selectedThread.runtime.activeRunId ?? undefined,
116 };117 };
117 }, [selectedThreadDetail, selectedThreadShell]);118 }, [selectedThreadDetail, selectedThreadShell]);
118 119
@@ -123,15 +124,13 @@ export function useThreadComposerState() { · 4 unmodified lines
123 }124 }
124 125
125 return deriveActiveWorkStartedAt(126 return deriveActiveWorkStartedAt(
126 selectedThread.latestTurn,127 selectedThread.latestRun,
127 selectedThreadSessionActivity,128 selectedThreadSessionActivity,
128 null,129 null,
129 );130 );
130 }, [selectedThreadDetail, selectedThreadSessionActivity, selectedThreadShell]);131 }, [selectedThreadDetail, selectedThreadSessionActivity, selectedThreadShell]);
131 132
132 const activeThreadBusy =133 const activeThreadBusy = !!selectedThread && threadRuntimeIsActive(selectedThread.runtime);
133 !!selectedThread &&
134 (selectedThread.session?.status === "running" || selectedThread.session?.status === "starting");
135 134
136 const onSendMessage = useCallback(async () => {135 const onSendMessage = useCallback(async () => {
137 if (!selectedThreadShell) {136 if (!selectedThreadShell) {
apps/mobile/src/state/use-thread-outbox-drain.ts+5−4
@@ -1,7 +1,7 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import type {2import {
3 EnvironmentProject,3 threadRuntimeIsActive,
4 EnvironmentThreadShell,4 type EnvironmentThreadShell,
5} from "@t3tools/client-runtime/state/shell";5} from "@t3tools/client-runtime/state/shell";
6import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";6import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
7import {7import {
@@ -221,6 +221,7 @@ export function useThreadOutboxDrain(): void { · 213 unmodified lines
221 environmentId: queuedMessage.environmentId,221 environmentId: queuedMessage.environmentId,
222 input: {222 input: {
223 commandId: queuedMessage.commandId,223 commandId: queuedMessage.commandId,
224 creationSource: "mobile",
224 threadId: queuedMessage.threadId,225 threadId: queuedMessage.threadId,
225 message: {226 message: {
226 messageId: queuedMessage.messageId,227 messageId: queuedMessage.messageId,
@@ -314,7 +315,7 @@ export function useThreadOutboxDrain(): void { · 87 unmodified lines
314 threadExists: thread !== undefined,315 threadExists: thread !== undefined,
315 shellStatus,316 shellStatus,
316 environmentConnected: environment?.connectionState === "connected",317 environmentConnected: environment?.connectionState === "connected",
317 threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting",318 threadBusy: threadRuntimeIsActive(thread?.runtime),
318 });319 });
319 if (deliveryAction === "wait") {320 if (deliveryAction === "wait") {
320 continue;321 continue;
apps/mobile/src/test-fixtures.ts+130−0
@@ -0,0 +1,130 @@
1import {
2 presentThread,
3 presentThreadShell,
4 type EnvironmentThread,
5 type EnvironmentThreadShell,
6} from "@t3tools/client-runtime/state/shell";
7import {
8 EnvironmentId,
9 ProjectId,
10 ProviderInstanceId,
11 ThreadId,
12 type OrchestrationV2ThreadProjection,
13 type OrchestrationV2ThreadShell,
14} from "@t3tools/contracts";
15import * as DateTime from "effect/DateTime";
16
17const DEFAULT_TIMESTAMP = "2026-01-01T00:00:00.000Z";
18
19export 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
57export 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
75export 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
3Node.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
7 7
8import {8import {
9 createAttachmentId,9 createAttachmentId,
10 createDeterministicAttachmentId,
10 parseThreadSegmentFromAttachmentId,11 parseThreadSegmentFromAttachmentId,
11 resolveAttachmentPathById,12 resolveAttachmentPathById,
12} from "./attachmentStore.ts";13} from "./attachmentStore.ts";
13 14
14describe("attachmentStore", () => {15describe("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
15 it("sanitizes thread ids when creating attachment ids", () => {26 it("sanitizes thread ids when creating attachment ids", () => {
16 const attachmentId = createAttachmentId("thread.folder/unsafe space");27 const attachmentId = createAttachmentId("thread.folder/unsafe space");
17 expect(attachmentId).toBeTruthy();28 expect(attachmentId).toBeTruthy();
apps/server/src/attachmentStore.ts+11−0
@@ -42,6 +42,17 @@ export function createAttachmentId(threadId: string): string | null { · 41 unmodified lines
42 return `${threadSegment}-${NodeCrypto.randomUUID()}`;42 return `${threadSegment}-${NodeCrypto.randomUUID()}`;
43}43}
44 44
45export 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
45export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null {56export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null {
46 const normalizedId = normalizeAttachmentRelativePath(attachmentId);57 const normalizedId = normalizeAttachmentRelativePath(attachmentId);
47 if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {58 if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {
apps/server/src/orchestration-v2/FoundationPersistence.test.ts+30−0
@@ -11,7 +11,9 @@ import { · 10 unmodified lines
11 type OrchestrationV2AppThread,11 type OrchestrationV2AppThread,
12 type OrchestrationV2DomainEvent,12 type OrchestrationV2DomainEvent,
13 ProjectId,13 ProjectId,
14 ProviderDriverKind,
14 ProviderInstanceId,15 ProviderInstanceId,
16 ProviderThreadId,
15 RunId,17 RunId,
16 ThreadId,18 ThreadId,
17 TurnItemId,19 TurnItemId,
@@ -60,6 +62,7 @@ const TestLayer = Layer.mergeAll( · 42 unmodified lines
60);62);
61 63
62const providerInstanceId = ProviderInstanceId.make("codex");64const providerInstanceId = ProviderInstanceId.make("codex");
65const providerDriver = ProviderDriverKind.make("codex");
63const modelSelection = {66const modelSelection = {
64 instanceId: providerInstanceId,67 instanceId: providerInstanceId,
65 model: "gpt-5.4",68 model: "gpt-5.4",
@@ -404,7 +407,32 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { · 338 unmodified lines
404 const now = yield* DateTime.now;407 const now = yield* DateTime.now;
405 const threadId = ThreadId.make("thread:foundation-many-items");408 const threadId = ThreadId.make("thread:foundation-many-items");
406 const runId = RunId.make("run:foundation-many-items");409 const runId = RunId.make("run:foundation-many-items");
410 const providerThreadId = ProviderThreadId.make("provider-thread:foundation-many-items");
407 const thread = makeThread(threadId, now);411 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;
408 const runEvent = {436 const runEvent = {
409 id: EventId.make("event:foundation-many-items:run"),437 id: EventId.make("event:foundation-many-items:run"),
410 type: "run.created" as const,438 type: "run.created" as const,
@@ -467,11 +495,13 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { · 56 unmodified lines
467 yield* eventSink.write({495 yield* eventSink.write({
468 events: [496 events: [
469 threadCreatedEvent({ id: "event:foundation-many-items:thread", thread, now }),497 threadCreatedEvent({ id: "event:foundation-many-items:thread", thread, now }),
498 providerThreadEvent,
470 runEvent,499 runEvent,
471 ...itemEvents,500 ...itemEvents,
472 ],501 ],
473 });502 });
474 const beforeUpdate = yield* projectionStore.getThreadProjection(threadId);503 const beforeUpdate = yield* projectionStore.getThreadProjection(threadId);
504 assert.equal(beforeUpdate.thread.activeProviderThreadId, providerThreadId);
475 const ordinals = beforeUpdate.turnItems.map((item) => item.ordinal);505 const ordinals = beforeUpdate.turnItems.map((item) => item.ordinal);
476 assert.lengthOf(ordinals, 151);506 assert.lengthOf(ordinals, 151);
477 assert.equal(new Set(ordinals).size, 151);507 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
1649 const projection = yield* getProjectionWithPendingEvents(command.threadId, events);1649 const projection = yield* getProjectionWithPendingEvents(command.threadId, events);
1650 const modelSelection = command.modelSelection ?? projection.thread.modelSelection;1650 const modelSelection = command.modelSelection ?? projection.thread.modelSelection;
1651 const dispatchMode = command.dispatchMode;1651 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 });
1652 1700
1653 if (dispatchMode.type === "steer_active" || dispatchMode.type === "restart_active") {1701 if (dispatchMode.type === "steer_active" || dispatchMode.type === "restart_active") {
1654 yield* dispatchSteerIntoRun({1702 yield* dispatchSteerIntoRun({
@@ -1783,6 +1831,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 128 unmodified lines
1783 completedAt: null,1831 completedAt: null,
1784 checkpointId: null,1832 checkpointId: null,
1785 contextHandoffId: null,1833 contextHandoffId: null,
1834 ...(command.sourcePlanRef === undefined ? {} : { sourcePlanRef: command.sourcePlanRef }),
1786 };1835 };
1787 const attempt: OrchestrationV2RunAttempt = {1836 const attempt: OrchestrationV2RunAttempt = {
1788 id: attemptId,1837 id: attemptId,
@@ -1861,6 +1910,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 72 unmodified lines
1861 occurredAt: now,1910 occurredAt: now,
1862 payload: run,1911 payload: run,
1863 });1912 });
1913 yield* completeSourcePlan(now);
1864 yield* emitEvent({1914 yield* emitEvent({
1865 type: "run-attempt.created",1915 type: "run-attempt.created",
1866 threadId: command.threadId,1916 threadId: command.threadId,
@@ -2030,6 +2080,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 163 unmodified lines
2030 completedAt: null,2080 completedAt: null,
2031 checkpointId: null,2081 checkpointId: null,
2032 contextHandoffId: null,2082 contextHandoffId: null,
2083 ...(command.sourcePlanRef === undefined ? {} : { sourcePlanRef: command.sourcePlanRef }),
2033 };2084 };
2034 const attempt: OrchestrationV2RunAttempt = {2085 const attempt: OrchestrationV2RunAttempt = {
2035 id: attemptId,2086 id: attemptId,
@@ -2116,6 +2167,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 80 unmodified lines
2116 occurredAt: now,2167 occurredAt: now,
2117 payload: run,2168 payload: run,
2118 });2169 });
2170 yield* completeSourcePlan(now);
2119 yield* emitEvent({2171 yield* emitEvent({
2120 type: "run-attempt.created",2172 type: "run-attempt.created",
2121 threadId: command.threadId,2173 threadId: command.threadId,
@@ -2613,6 +2665,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 491 unmodified lines
2613 checkpointId: null,2665 checkpointId: null,
2614 contextHandoffId:2666 contextHandoffId:
2615 portableForkHandoff?.id ?? providerSwitchHandoff?.id ?? mergeBackHandoff?.id ?? null,2667 portableForkHandoff?.id ?? providerSwitchHandoff?.id ?? mergeBackHandoff?.id ?? null,
2668 ...(command.sourcePlanRef === undefined ? {} : { sourcePlanRef: command.sourcePlanRef }),
2616 };2669 };
2617 const attempt: OrchestrationV2RunAttempt = {2670 const attempt: OrchestrationV2RunAttempt = {
2618 id: attemptId,2671 id: attemptId,
@@ -2917,6 +2970,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 298 unmodified lines
2917 occurredAt: now,2970 occurredAt: now,
2918 payload: run,2971 payload: run,
2919 });2972 });
2973 yield* completeSourcePlan(now);
2920 yield* emitEvent({2974 yield* emitEvent({
2921 type: "run-attempt.created",2975 type: "run-attempt.created",
2922 threadId: command.threadId,2976 threadId: command.threadId,
apps/server/src/orchestration-v2/ProjectionStore.ts+44−0
@@ -223,6 +223,13 @@ export function applyToProjection( · 222 unmodified lines
223 case "provider-thread.updated":223 case "provider-thread.updated":
224 return {224 return {
225 ...base,225 ...base,
226 thread:
227 event.payload.appThreadId === base.thread.id
228 ? {
229 ...base.thread,
230 activeProviderThreadId: event.payload.id,
231 }
232 : base.thread,
226 providerThreads: upsertById(base.providerThreads, event.payload),233 providerThreads: upsertById(base.providerThreads, event.payload),
227 };234 };
228 case "provider-turn.updated":235 case "provider-turn.updated":
@@ -288,6 +295,8 @@ type ShellThreadRow = { · 59 unmodified lines
288 readonly active_run_id: string | null;295 readonly active_run_id: string | null;
289 readonly pending_request_payload_json: string | null;296 readonly pending_request_payload_json: string | null;
290 readonly latest_message_payload_json: string | null;297 readonly latest_message_payload_json: string | null;
298 readonly latest_user_message_at: string | null;
299 readonly has_actionable_proposed_plan: number;
291 readonly item_count: number;300 readonly item_count: number;
292};301};
293 302
@@ -638,6 +647,13 @@ export function threadShellFromProjection( · 344 unmodified lines
638 (left, right) =>647 (left, right) =>
639 DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt),648 DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt),
640 )[0] ?? null;649 )[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;
641 return {657 return {
642 createdBy: projection.thread.createdBy,658 createdBy: projection.thread.createdBy,
643 creationSource: projection.thread.creationSource,659 creationSource: projection.thread.creationSource,
@@ -673,6 +689,10 @@ export function threadShellFromProjection( · 29 unmodified lines
673 text: latestVisibleMessage.text,689 text: latestVisibleMessage.text,
674 updatedAt: latestVisibleMessage.updatedAt,690 updatedAt: latestVisibleMessage.updatedAt,
675 },691 },
692 latestUserMessageAt: latestUserMessage?.updatedAt ?? null,
693 hasActionableProposedPlan: projection.plans.some(
694 (plan) => plan.kind === "proposed_plan" && plan.status === "active",
695 ),
676 itemCount: activeLocalTurnItems(projection).length,696 itemCount: activeLocalTurnItems(projection).length,
677 visibleItemCount: projection.visibleTurnItems.length,697 visibleItemCount: projection.visibleTurnItems.length,
678 createdAt: projection.thread.createdAt,698 createdAt: projection.thread.createdAt,
@@ -693,6 +713,8 @@ type ShellThreadState = { · 14 unmodified lines
693 readonly activeRunId: RunId | null;713 readonly activeRunId: RunId | null;
694 readonly pendingRuntimeRequest: OrchestrationV2ThreadProjection["runtimeRequests"][number] | null;714 readonly pendingRuntimeRequest: OrchestrationV2ThreadProjection["runtimeRequests"][number] | null;
695 readonly latestVisibleMessage: OrchestrationV2ConversationMessage | null;715 readonly latestVisibleMessage: OrchestrationV2ConversationMessage | null;
716 readonly latestUserMessageAt: DateTime.Utc | null;
717 readonly hasActionableProposedPlan: boolean;
696 readonly itemCount: number;718 readonly itemCount: number;
697 readonly updatedAt: OrchestrationV2ThreadProjection["updatedAt"];719 readonly updatedAt: OrchestrationV2ThreadProjection["updatedAt"];
698 readonly runOrdinalById: ReadonlyMap<RunId, number>;720 readonly runOrdinalById: ReadonlyMap<RunId, number>;
@@ -819,6 +841,8 @@ function shellFromState(input: { · 120 unmodified lines
819 text: input.state.latestVisibleMessage.text,841 text: input.state.latestVisibleMessage.text,
820 updatedAt: input.state.latestVisibleMessage.updatedAt,842 updatedAt: input.state.latestVisibleMessage.updatedAt,
821 },843 },
844 latestUserMessageAt: input.state.latestUserMessageAt,
845 hasActionableProposedPlan: input.state.hasActionableProposedPlan,
822 itemCount: input.state.itemCount,846 itemCount: input.state.itemCount,
823 visibleItemCount: input.visibleItemCount,847 visibleItemCount: input.visibleItemCount,
824 createdAt: input.state.thread.createdAt,848 createdAt: input.state.thread.createdAt,
@@ -1949,6 +1973,21 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 1124 unmodified lines
1949 ORDER BY message.updated_at DESC, message.message_id DESC1973 ORDER BY message.updated_at DESC, message.message_id DESC
1950 LIMIT 11974 LIMIT 1
1951 ) AS latest_message_payload_json,1975 ) 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,
1952 (1991 (
1953 SELECT COUNT(*)1992 SELECT COUNT(*)
1954 FROM orchestration_v2_projection_turn_items i1993 FROM orchestration_v2_projection_turn_items i
@@ -2015,6 +2054,11 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 60 unmodified lines
2015 activeRunId: row.active_run_id === null ? null : RunId.make(row.active_run_id),2054 activeRunId: row.active_run_id === null ? null : RunId.make(row.active_run_id),
2016 pendingRuntimeRequest,2055 pendingRuntimeRequest,
2017 latestVisibleMessage,2056 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,
2018 itemCount: row.item_count,2062 itemCount: row.item_count,
2019 updatedAt: thread.updatedAt,2063 updatedAt: thread.updatedAt,
2020 runOrdinalById:2064 runOrdinalById:
apps/server/src/orchestration-v2/RuntimePolicy.test.ts+99−0
@@ -0,0 +1,99 @@
1import { assert, it } from "@effect/vitest";
2import {
3 type ModelSelection,
4 type OrchestrationV2AppThread,
5 ProjectId,
6 ProviderInstanceId,
7 ThreadId,
8} from "@t3tools/contracts";
9import * as DateTime from "effect/DateTime";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as Option from "effect/Option";
13
14import * as ProjectionProjects from "../persistence/Services/ProjectionProjects.ts";
15import { layerFromProjectRepository, RuntimePolicyV2 } from "./RuntimePolicy.ts";
16
17const projectId = ProjectId.make("project:runtime-policy");
18const providerInstanceId = ProviderInstanceId.make("codex");
19const modelSelection = {
20 instanceId: providerInstanceId,
21 model: "gpt-5.5",
22} satisfies ModelSelection;
23
24function 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
55const 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
75it.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 @@
1import { ModelSelection, OrchestrationV2AppThread, ProviderDriverKind } from "@t3tools/contracts";1import {
2 ModelSelection,
3 OrchestrationV2AppThread,
4 ProjectId,
5 ProviderInstanceId,
6} from "@t3tools/contracts";
2import * as Context from "effect/Context";7import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
6 12
13import * as ProjectionProjects from "../persistence/Services/ProjectionProjects.ts";
7import {14import {
8 ProviderAdapterV2RuntimePolicy,15 ProviderAdapterV2RuntimePolicy,
9 type ProviderAdapterV2RuntimePolicy as ProviderAdapterV2RuntimePolicyType,16 type ProviderAdapterV2RuntimePolicy as ProviderAdapterV2RuntimePolicyType,
@@ -15,12 +22,13 @@ import { · 5 unmodified lines
15export class RuntimePolicyResolveError extends Schema.TaggedErrorClass<RuntimePolicyResolveError>()(22export class RuntimePolicyResolveError extends Schema.TaggedErrorClass<RuntimePolicyResolveError>()(
16 "RuntimePolicyResolveError",23 "RuntimePolicyResolveError",
17 {24 {
18 driver: ProviderDriverKind,25 projectId: ProjectId,
26 providerInstanceId: ProviderInstanceId,
19 cause: Schema.optional(Schema.Defect()),27 cause: Schema.optional(Schema.Defect()),
20 },28 },
21) {29) {
22 override get message(): string {30 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}.`;
24 }32 }
25}33}
26 34
@@ -61,6 +69,51 @@ export const layer: Layer.Layer<RuntimePolicyV2> = Layer.succeed(RuntimePolicyV2 · 34 unmodified lines
61 }),69 }),
62});70});
63 71
72export 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
64export function layerWithOverride(117export function layerWithOverride(
65 override: RuntimePolicyV2Override,118 override: RuntimePolicyV2Override,
66): Layer.Layer<RuntimePolicyV2, never, RuntimePolicyV2> {119): 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
80 (80 (
81 command: Parameters<ThreadManagement.ThreadManagementService["Service"]["dispatch"]>[0],81 command: Parameters<ThreadManagement.ThreadManagementService["Service"]["dispatch"]>[0],
82 ): Effect.Effect<OrchestratorV2DispatchResult, OrchestratorDispatchError> => {82 ): 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 }
83 if (command.type !== "thread.create") return Effect.die("unexpected command");97 if (command.type !== "thread.create") return Effect.die("unexpected command");
84 if (options?.failCreate) {98 if (options?.failCreate) {
85 return Effect.fail(99 return Effect.fail(
@@ -90,16 +104,37 @@ const makeLayer = (options?: { readonly failCreate?: boolean; readonly failSetup · 4 unmodified lines
90 }),104 }),
91 );105 );
92 }106 }
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 });
94 return Effect.succeed({ sequence: 1, storedEvents: [] });117 return Effect.succeed({ sequence: 1, storedEvents: [] });
95 },118 },
96 );119 );
97 const createWorktree = vi.fn(() =>120 const createWorktree = vi.fn(
98 Effect.succeed({121 (_input: Parameters<GitWorkflow.GitWorkflowService["Service"]["createWorktree"]>[0]) =>
99 worktree: { path: "/repo-worktrees/feature", refName: "feature", headSha: "abc" },122 Effect.succeed({
100 } as never),123 worktree: { path: "/repo-worktrees/feature", refName: "feature", headSha: "abc" },
124 } as never),
101 );125 );
102 const removeWorktree = vi.fn(() => Effect.void);126 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 );
103 const runForThread = vi.fn(() =>138 const runForThread = vi.fn(() =>
104 options?.failSetup139 options?.failSetup
105 ? Effect.fail(140 ? Effect.fail(
@@ -126,7 +161,12 @@ const makeLayer = (options?: { readonly failCreate?: boolean; readonly failSetup · 20 unmodified lines
126 getByWorkspaceRoot: () => Effect.succeed(Option.some(project)),161 getByWorkspaceRoot: () => Effect.succeed(Option.some(project)),
127 snapshot: Effect.die("unused"),162 snapshot: Effect.die("unused"),
128 }),163 }),
129 Layer.mock(GitWorkflow.GitWorkflowService)({ createWorktree, removeWorktree }),164 Layer.mock(GitWorkflow.GitWorkflowService)({
165 createWorktree,
166 fetchRemote,
167 removeWorktree,
168 resolveRemoteTrackingCommit,
169 }),
130 Layer.succeed(ProjectSetupScriptRunner.ProjectSetupScriptRunner, {170 Layer.succeed(ProjectSetupScriptRunner.ProjectSetupScriptRunner, {
131 runForThread,171 runForThread,
132 }),172 }),
@@ -150,8 +190,11 @@ const makeLayer = (options?: { readonly failCreate?: boolean; readonly failSetup · 17 unmodified lines
150 Layer.provideMerge(SqlitePersistenceMemory),190 Layer.provideMerge(SqlitePersistenceMemory),
151 ),191 ),
152 dispatch,192 dispatch,
193 fetchRemote,
153 createWorktree,194 createWorktree,
195 projections,
154 removeWorktree,196 removeWorktree,
197 resolveRemoteTrackingCommit,
155 runForThread,198 runForThread,
156 sendToThread,199 sendToThread,
157 };200 };
@@ -213,6 +256,104 @@ it.effect( · 55 unmodified lines
213 },256 },
214);257);
215 258
259it.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
286it.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
325it.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
216it.effect("removes a new worktree and does not create a thread when setup fails", () => {357it.effect("removes a new worktree and does not create a thread when setup fails", () => {
217 const test = makeLayer({ failSetup: true });358 const test = makeLayer({ failSetup: true });
218 return Effect.gen(function* () {359 return Effect.gen(function* () {
apps/server/src/orchestration-v2/ThreadLaunchService.ts+125−31
@@ -1,6 +1,7 @@
1import {1import {
2 CommandId,2 CommandId,
3 type ChatAttachment,3 type ChatAttachment,
4 type MessageId,
4 type ModelSelection,5 type ModelSelection,
5 type OrchestrationV2Actor,6 type OrchestrationV2Actor,
6 type OrchestrationV2CreationSource,7 type OrchestrationV2CreationSource,
@@ -27,20 +28,29 @@ import * as IdAllocator from "./IdAllocator.ts"; · 20 unmodified lines
27import * as ThreadManagement from "./ThreadManagementService.ts";28import * as ThreadManagement from "./ThreadManagementService.ts";
28 29
29export type ThreadLaunchWorkspaceStrategy =30export 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 }
31 | {37 | {
32 readonly type: "worktree";38 readonly type: "worktree";
33 readonly baseRef: string;39 readonly baseRef: string;
34 readonly branch?: string;40 readonly branch?: string | undefined;
41 readonly startFromOrigin?: boolean | undefined;
35 };42 };
36 43
37export interface ThreadLaunchInitialMessage {44export interface ThreadLaunchInitialMessage {
45 readonly messageId?: MessageId;
38 readonly text: string;46 readonly text: string;
39 readonly attachments: ReadonlyArray<ChatAttachment>;47 readonly attachments: ReadonlyArray<ChatAttachment>;
40}48}
41 49
42export interface ThreadLaunchInput {50export interface ThreadLaunchInput {
43 readonly commandId: CommandId;51 readonly commandId: CommandId;
52 readonly threadId?: ThreadId;
53 readonly reuseExistingThread?: boolean;
44 readonly projectId: ProjectId;54 readonly projectId: ProjectId;
45 readonly title: string;55 readonly title: string;
46 readonly modelSelection: ModelSelection;56 readonly modelSelection: ModelSelection;
@@ -69,6 +79,7 @@ export class ThreadLaunchError extends Schema.TaggedErrorClass<ThreadLaunchError · 22 unmodified lines
69 "provision-worktree",79 "provision-worktree",
70 "run-setup-script",80 "run-setup-script",
71 "create-thread",81 "create-thread",
82 "update-thread",
72 "dispatch-message",83 "dispatch-message",
73 "compensate-worktree",84 "compensate-worktree",
74 ]),85 ]),
@@ -185,11 +196,18 @@ export const make = Effect.gen(function* () { · 110 unmodified lines
185 ),196 ),
186 );197 );
187 const stored = yield* loadWorkflow(input);198 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 }
188 const threadId =205 const threadId =
189 stored === null206 stored === null
190 ? yield* ids.allocate207 ? (input.threadId ??
208 (yield* ids.allocate
191 .thread({ projectId: input.projectId })209 .thread({ projectId: input.projectId })
192 .pipe(Effect.mapError(mapError(input, "persist-workflow")))210 .pipe(Effect.mapError(mapError(input, "persist-workflow")))))
193 : ThreadId.make(stored.thread_id);211 : ThreadId.make(stored.thread_id);
194 if (stored?.status === "completed") {212 if (stored?.status === "completed") {
195 return {213 return {
@@ -201,7 +219,38 @@ export const make = Effect.gen(function* () { · 5 unmodified lines
201 };219 };
202 }220 }
203 221
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));
205 let title = stored?.title ?? input.title;254 let title = stored?.title ?? input.title;
206 if (input.initialMessage !== undefined) {255 if (input.initialMessage !== undefined) {
207 if (title === "New thread") {256 if (title === "New thread") {
@@ -235,15 +284,36 @@ export const make = Effect.gen(function* () { · 27 unmodified lines
235 branch = branch ?? input.workspaceStrategy.branch ?? `thread-${threadId}`;284 branch = branch ?? input.workspaceStrategy.branch ?? `thread-${threadId}`;
236 }285 }
237 286
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";
239 const threadCommitted = stored?.thread_committed === 1;293 const threadCommitted = stored?.thread_committed === 1;
240 let setupCommitted = stored?.setup_committed === 1;294 let setupCommitted = stored?.setup_committed === 1;
241 let messageCommitted = stored?.message_committed === 1;295 let messageCommitted = stored?.message_committed === 1;
242 if (input.workspaceStrategy.type === "worktree" && worktreePath === null) {296 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 }
243 const worktree = yield* git313 const worktree = yield* git
244 .createWorktree({314 .createWorktree({
245 cwd: project.workspaceRoot,315 cwd: project.workspaceRoot,
246 refName: input.workspaceStrategy.baseRef,316 refName: startRef,
247 newRefName: branch!,317 newRefName: branch!,
248 baseRefName: input.workspaceStrategy.baseRef,318 baseRefName: input.workspaceStrategy.baseRef,
249 path: null,319 path: null,
@@ -278,7 +348,7 @@ export const make = Effect.gen(function* () { · 28 unmodified lines
278 }),348 }),
279 );349 );
280 if (setupExit._tag === "Failure") {350 if (setupExit._tag === "Failure") {
281 if (worktreePath !== null && !threadCommitted) {351 if (ownsProvisionedWorktree && worktreePath !== null && !threadCommitted) {
282 yield* git352 yield* git
283 .removeWorktree({ cwd: project.workspaceRoot, path: worktreePath })353 .removeWorktree({ cwd: project.workspaceRoot, path: worktreePath })
284 .pipe(Effect.mapError(mapError(input, "compensate-worktree", threadId)));354 .pipe(Effect.mapError(mapError(input, "compensate-worktree", threadId)));
@@ -313,24 +383,36 @@ export const make = Effect.gen(function* () { · 28 unmodified lines
313 }383 }
314 384
315 if (!threadCommitted) {385 if (!threadCommitted) {
316 const createExit = yield* Effect.exit(386 const commitThread =
317 threads.dispatch({387 input.reuseExistingThread === true
318 type: "thread.create",388 ? Effect.gen(function* () {
319 commandId: input.commandId,389 yield* validateReusableThread();
320 threadId,390 return yield* threads.dispatch({
321 projectId: input.projectId,391 type: "thread.metadata.update",
322 title,392 commandId: input.commandId,
323 modelSelection: input.modelSelection,393 threadId,
324 runtimeMode: input.runtimeMode,394 title,
325 interactionMode: input.interactionMode,395 branch,
326 branch,396 worktreePath,
327 worktreePath,397 });
328 createdBy: input.createdBy,398 })
329 creationSource: input.creationSource,399 : threads.dispatch({
330 }),400 type: "thread.create",
331 );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);
332 if (createExit._tag === "Failure") {414 if (createExit._tag === "Failure") {
333 if (worktreePath !== null) {415 if (ownsProvisionedWorktree && worktreePath !== null) {
334 yield* git416 yield* git
335 .removeWorktree({ cwd: project.workspaceRoot, path: worktreePath })417 .removeWorktree({ cwd: project.workspaceRoot, path: worktreePath })
336 .pipe(Effect.mapError(mapError(input, "compensate-worktree", threadId)));418 .pipe(Effect.mapError(mapError(input, "compensate-worktree", threadId)));
@@ -339,17 +421,27 @@ export const make = Effect.gen(function* () { · 2 unmodified lines
339 }421 }
340 yield* saveWorkflow(input, {422 yield* saveWorkflow(input, {
341 threadId,423 threadId,
342 status: "thread_create_failed",424 status:
425 input.reuseExistingThread === true ? "thread_update_failed" : "thread_create_failed",
343 title,426 title,
344 worktreePath,427 worktreePath,
345 branch,428 branch,
346 setupCommitted,429 setupCommitted,
347 threadCommitted: false,430 threadCommitted: false,
348 messageCommitted,431 messageCommitted,
349 lastError: "Thread creation failed.",432 lastError:
433 input.reuseExistingThread === true
434 ? "Thread workspace update failed."
435 : "Thread creation failed.",
350 });436 });
351 return yield* Effect.failCause(createExit.cause).pipe(437 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 ),
353 );445 );
354 }446 }
355 yield* saveWorkflow(input, {447 yield* saveWorkflow(input, {
@@ -365,9 +457,11 @@ export const make = Effect.gen(function* () { · 9 unmodified lines
365 }457 }
366 458
367 if (input.initialMessage !== undefined && !messageCommitted) {459 if (input.initialMessage !== undefined && !messageCommitted) {
368 const messageId = yield* ids.allocate460 const messageId =
369 .message({ threadId, ordinal: 1 })461 input.initialMessage.messageId ??
370 .pipe(Effect.mapError(mapError(input, "dispatch-message", threadId)));462 (yield* ids.allocate
463 .message({ threadId, ordinal: 1 })
464 .pipe(Effect.mapError(mapError(input, "dispatch-message", threadId))));
371 yield* threads465 yield* threads
372 .sendToThread({466 .sendToThread({
373 projectId: input.projectId,467 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
32import { layer as providerTurnStartServiceLayer } from "./ProviderTurnStartService.ts";32import { layer as providerTurnStartServiceLayer } from "./ProviderTurnStartService.ts";
33import { layer as runExecutionServiceLayer } from "./RunExecutionService.ts";33import { layer as runExecutionServiceLayer } from "./RunExecutionService.ts";
34import { layer as runFinalizationServiceLayer } from "./RunFinalizationService.ts";34import { layer as runFinalizationServiceLayer } from "./RunFinalizationService.ts";
35import { layer as runtimePolicyLayer } from "./RuntimePolicy.ts";35import { layerFromProjectRepository as runtimePolicyLayerFromProjectRepository } from "./RuntimePolicy.ts";
36import { layer as runtimeRequestServiceLayer } from "./RuntimeRequestService.ts";36import { layer as runtimeRequestServiceLayer } from "./RuntimeRequestService.ts";
37import { layer as threadManagementServiceLayer } from "./ThreadManagementService.ts";37import { layer as threadManagementServiceLayer } from "./ThreadManagementService.ts";
38import { layer as threadLaunchServiceLayer } from "./ThreadLaunchService.ts";38import { layer as threadLaunchServiceLayer } from "./ThreadLaunchService.ts";
@@ -40,6 +40,13 @@ import { layer as threadLifecycleServiceLayer } from "./ThreadLifecycleService.t · 1 unmodified line
40import { layer as threadForkServiceLayer } from "./ThreadForkService.ts";40import { layer as threadForkServiceLayer } from "./ThreadForkService.ts";
41import { layer as turnItemPositionStoreLayer } from "./TurnItemPositionStore.ts";41import { layer as turnItemPositionStoreLayer } from "./TurnItemPositionStore.ts";
42 42
43export const ProjectServiceLayerLive = projectServiceLayer.pipe(
44 Layer.provide(Layer.merge(ProjectionProjectRepositoryLive, OrchestrationLayerLive)),
45);
46const runtimePolicyProvided = runtimePolicyLayerFromProjectRepository.pipe(
47 Layer.provide(ProjectionProjectRepositoryLive),
48);
49
43const eventStoreProvided = eventStoreLayer.pipe(50const eventStoreProvided = eventStoreLayer.pipe(
44 Layer.provide(OrchestrationEventInfrastructureLayerLive),51 Layer.provide(OrchestrationEventInfrastructureLayerLive),
45);52);
@@ -111,7 +118,7 @@ const providerTurnStartServiceProvided = providerTurnStartServiceLayer.pipe( · 65 unmodified lines
111 projectionStoreLayer,118 projectionStoreLayer,
112 providerSessionManagerProvided,119 providerSessionManagerProvided,
113 runExecutionServiceProvided,120 runExecutionServiceProvided,
114 runtimePolicyLayer,121 runtimePolicyProvided,
115 ),122 ),
116 ),123 ),
117);124);
@@ -130,7 +137,7 @@ const checkpointRollbackServiceProvided = checkpointRollbackServiceLayer.pipe( · 12 unmodified lines
130 idAllocatorLayer,137 idAllocatorLayer,
131 projectionStoreLayer,138 projectionStoreLayer,
132 providerSessionManagerProvided,139 providerSessionManagerProvided,
133 runtimePolicyLayer,140 runtimePolicyProvided,
134 ),141 ),
135 ),142 ),
136);143);
@@ -189,7 +196,7 @@ const orchestratorProvided = orchestratorLayer.pipe( · 52 unmodified lines
189 idAllocatorLayer,196 idAllocatorLayer,
190 providerAdapterRegistryProvided,197 providerAdapterRegistryProvided,
191 providerEventIngestorProvided,198 providerEventIngestorProvided,
192 runtimePolicyLayer,199 runtimePolicyProvided,
193 providerSessionManagerProvided,200 providerSessionManagerProvided,
194 providerSwitchServiceProvided,201 providerSwitchServiceProvided,
195 runExecutionServiceProvided,202 runExecutionServiceProvided,
@@ -201,9 +208,6 @@ const orchestratorProvided = orchestratorLayer.pipe( · 5 unmodified lines
201const threadManagementProvided = threadManagementServiceLayer.pipe(208const threadManagementProvided = threadManagementServiceLayer.pipe(
202 Layer.provide(orchestratorProvided),209 Layer.provide(orchestratorProvided),
203);210);
204export const ProjectServiceLayerLive = projectServiceLayer.pipe(
205 Layer.provide(Layer.merge(ProjectionProjectRepositoryLive, OrchestrationLayerLive)),
206);
207export const ProjectSetupScriptRunnerLayerLive = projectSetupScriptRunnerLayer.pipe(211export const ProjectSetupScriptRunnerLayerLive = projectSetupScriptRunnerLayer.pipe(
208 Layer.provide(ProjectServiceLayerLive),212 Layer.provide(ProjectServiceLayerLive),
209);213);
apps/server/src/ws.ts+199−28
@@ -1,8 +1,11 @@
1import * as DateTime from "effect/DateTime";1import * as DateTime from "effect/DateTime";
2import * as Duration from "effect/Duration";2import * as Duration from "effect/Duration";
3import * as Encoding from "effect/Encoding";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";7import * as Option from "effect/Option";
8import * as Path from "effect/Path";
6import * as Queue from "effect/Queue";9import * as Queue from "effect/Queue";
7import * as Ref from "effect/Ref";10import * as Ref from "effect/Ref";
8import * as Result from "effect/Result";11import * as Result from "effect/Result";
@@ -13,9 +16,11 @@ import { · 4 unmodified lines
13 type AuthAccessStreamEvent,16 type AuthAccessStreamEvent,
14 type AuthEnvironmentScope,17 type AuthEnvironmentScope,
15 AuthSessionId,18 AuthSessionId,
19 CommandId,
16 type DiscoveredLocalServerList,20 type DiscoveredLocalServerList,
17 type GitActionProgressEvent,21 type GitActionProgressEvent,
18 type GitManagerServiceError,22 type GitManagerServiceError,
23 type MessageId,
19 OrchestrationGetFullThreadDiffError,24 OrchestrationGetFullThreadDiffError,
20 OrchestrationGetTurnDiffError,25 OrchestrationGetTurnDiffError,
21 ORCHESTRATION_V2_WS_METHODS,26 ORCHESTRATION_V2_WS_METHODS,
@@ -28,18 +33,21 @@ import { · 6 unmodified lines
28 type ProjectEntriesFailure,33 type ProjectEntriesFailure,
29 type ProjectFileFailure,34 type ProjectFileFailure,
30 type ProjectFileOperation,35 type ProjectFileOperation,
36 type ProjectMutation,
31 ProjectListEntriesError,37 ProjectListEntriesError,
32 ProjectReadFileError,38 ProjectReadFileError,
33 ProjectSearchContentsError,39 ProjectSearchContentsError,
34 ProjectSearchEntriesError,40 ProjectSearchEntriesError,
35 ProjectWriteFileError,41 ProjectWriteFileError,
42 ProjectMutationError,
36 RelayClientInstallFailedError,43 RelayClientInstallFailedError,
37 type RelayClientInstallProgressEvent,44 type RelayClientInstallProgressEvent,
38 type FilesystemBrowseFailure,45 type FilesystemBrowseFailure,
39 FilesystemBrowseError,46 FilesystemBrowseError,
40 AssetWorkspaceContextNotFoundError,47 AssetWorkspaceContextNotFoundError,
41 AssetWorkspaceContextResolutionError,48 AssetWorkspaceContextResolutionError,
42 RpcClientId,49 ChatAttachmentId,
50 PersistChatAttachmentsError,
43 EnvironmentAuthorizationError,51 EnvironmentAuthorizationError,
44 ThreadId,52 ThreadId,
45 type TerminalAttachStreamEvent,53 type TerminalAttachStreamEvent,
@@ -81,6 +89,8 @@ import * as TerminalManager from "./terminal/Manager.ts"; · 35 unmodified lines
81import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";89import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";
82import * as PreviewManager from "./preview/Manager.ts";90import * as PreviewManager from "./preview/Manager.ts";
83import { issueAssetUrl } from "./assets/AssetAccess.ts";91import { issueAssetUrl } from "./assets/AssetAccess.ts";
92import { attachmentRelativePath, createDeterministicAttachmentId } from "./attachmentStore.ts";
93import { parseBase64DataUrl } from "./imageMime.ts";
84import * as PortScanner from "./preview/PortScanner.ts";94import * as PortScanner from "./preview/PortScanner.ts";
85import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";95import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
86import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";96import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
@@ -118,6 +128,73 @@ function unexpectedCompatibilityError(error: never): never { · 31 unmodified lines
118 throw new Error(`Unhandled compatibility error: ${String(error)}`);128 throw new Error(`Unhandled compatibility error: ${String(error)}`);
119}129}
120 130
131const 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
121function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesError): {198function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesError): {
122 readonly failure: ProjectEntriesFailure;199 readonly failure: ProjectEntriesFailure;
123 readonly normalizedCwd?: string;200 readonly normalizedCwd?: string;
@@ -253,8 +330,11 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ · 129 unmodified lines
253 [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope],330 [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope],
254 [WS_METHODS.projectsSearchEntries, AuthOrchestrationReadScope],331 [WS_METHODS.projectsSearchEntries, AuthOrchestrationReadScope],
255 [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope],332 [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope],
333 [WS_METHODS.projectsMutate, AuthOrchestrationOperateScope],
256 [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope],334 [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope],
257 [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope],335 [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope],
336 [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope],
337 [WS_METHODS.assetsPersistChatAttachments, AuthOrchestrationOperateScope],
258 [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope],338 [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope],
259 [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope],339 [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope],
260 [WS_METHODS.vcsPull, AuthOrchestrationOperateScope],340 [WS_METHODS.vcsPull, AuthOrchestrationOperateScope],
@@ -623,20 +703,28 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 362 unmodified lines
623 },703 },
624 );704 );
625 705
626 const getOrchestrationV2ArchivedShellSnapshot = threadManagement.getShellSnapshot().pipe(706 const getOrchestrationV2ArchivedShellSnapshot = sql
627 Effect.map((snapshot) => ({707 .withTransaction(
628 schemaVersion: snapshot.schemaVersion,708 Effect.gen(function* () {
629 snapshotSequence: snapshot.snapshotSequence,709 const projects = yield* projectionSnapshotQuery.getShellSnapshot();
630 threads: snapshot.archivedThreads,710 const threads = yield* threadManagement.getShellSnapshot();
631 })),711 return {
632 Effect.mapError(712 schemaVersion: threads.schemaVersion,
633 (cause) =>713 snapshotSequence: yield* applicationEvents.latestApplicationSequence,
634 new OrchestrationV2GetShellSnapshotError({714 projects: projects.projects,
635 message: "Failed to load archived thread snapshot",715 threads: threads.archivedThreads,
636 cause,716 } as const;
637 }),717 }),
638 ),718 )
639 );719 .pipe(
720 Effect.mapError(
721 (cause) =>
722 new OrchestrationV2GetShellSnapshotError({
723 message: "Failed to load archived thread snapshot",
724 cause,
725 }),
726 ),
727 );
640 728
641 const subscribeOrchestrationV2ArchivedShell = Effect.fn(729 const subscribeOrchestrationV2ArchivedShell = Effect.fn(
642 "ws.orchestrationV2.subscribeArchivedShell",730 "ws.orchestrationV2.subscribeArchivedShell",
@@ -671,6 +759,64 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 28 unmodified lines
671 return Stream.concat(Stream.make({ kind: "snapshot" as const, snapshot }), live);759 return Stream.concat(Stream.make({ kind: "snapshot" as const, snapshot }), live);
672 });760 });
673 761
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
674 const handlers = ServerWsRpcGroup.of({820 const handlers = ServerWsRpcGroup.of({
675 [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command) =>821 [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command) =>
676 observeRpcEffect(822 observeRpcEffect(
@@ -680,7 +826,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 3 unmodified lines
680 threadManagement.dispatch(826 threadManagement.dispatch(
681 ThreadManagementService.withCreationProvenance(command, {827 ThreadManagementService.withCreationProvenance(command, {
682 createdBy: "user",828 createdBy: "user",
683 creationSource: "web",829 creationSource: "creationSource" in command ? command.creationSource : "web",
684 }),830 }),
685 ),831 ),
686 )832 )
@@ -771,26 +917,29 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 84 unmodified lines
771 .enqueueCommand(917 .enqueueCommand(
772 threadLaunch.launch({918 threadLaunch.launch({
773 commandId: input.commandId,919 commandId: input.commandId,
920 ...(input.threadId === undefined ? {} : { threadId: input.threadId }),
921 ...(input.reuseExistingThread === undefined
922 ? {}
923 : { reuseExistingThread: input.reuseExistingThread }),
774 projectId: input.projectId,924 projectId: input.projectId,
775 title: input.title,925 title: input.title,
776 modelSelection: input.modelSelection,926 modelSelection: input.modelSelection,
777 runtimeMode: input.runtimeMode,927 runtimeMode: input.runtimeMode,
778 interactionMode: input.interactionMode,928 interactionMode: input.interactionMode,
779 workspaceStrategy:929 workspaceStrategy: input.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 },
789 ...(input.initialMessage === undefined930 ...(input.initialMessage === undefined
790 ? {}931 ? {}
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 }),
792 createdBy: "user",941 createdBy: "user",
793 creationSource: "web",942 creationSource: input.creationSource ?? "web",
794 }),943 }),
795 )944 )
796 .pipe(945 .pipe(
@@ -1137,6 +1286,22 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 340 unmodified lines
1137 ),1286 ),
1138 { "rpc.aggregate": "workspace" },1287 { "rpc.aggregate": "workspace" },
1139 ),1288 ),
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 ),
1140 [WS_METHODS.shellOpenInEditor]: (input) =>1305 [WS_METHODS.shellOpenInEditor]: (input) =>
1141 observeRpcEffect(WS_METHODS.shellOpenInEditor, externalLauncher.launchEditor(input), {1306 observeRpcEffect(WS_METHODS.shellOpenInEditor, externalLauncher.launchEditor(input), {
1142 "rpc.aggregate": "workspace",1307 "rpc.aggregate": "workspace",
@@ -1195,6 +1360,12 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 52 unmodified lines
1195 }),1360 }),
1196 { "rpc.aggregate": "workspace" },1361 { "rpc.aggregate": "workspace" },
1197 ),1362 ),
1363 [WS_METHODS.assetsPersistChatAttachments]: (input) =>
1364 observeRpcEffect(
1365 WS_METHODS.assetsPersistChatAttachments,
1366 persistChatAttachments(input).pipe(Effect.map((attachments) => ({ attachments }))),
1367 { "rpc.aggregate": "orchestration" },
1368 ),
1198 [WS_METHODS.subscribeVcsStatus]: (input) =>1369 [WS_METHODS.subscribeVcsStatus]: (input) =>
1199 observeRpcStream(1370 observeRpcStream(
1200 WS_METHODS.subscribeVcsStatus,1371 WS_METHODS.subscribeVcsStatus,
apps/web/src/components/BranchToolbarBranchSelector.tsx+2−0
@@ -116,6 +116,8 @@ export function BranchToolbarBranchSelector({ · 115 unmodified lines
116 () => scopeThreadRef(environmentId, threadId),116 () => scopeThreadRef(environmentId, threadId),
117 [environmentId, threadId],117 [environmentId, threadId],
118 );118 );
119 const serverThread = useThread(threadRef);
120 const serverSession = serverThread?.runtime ?? null;
119 const draftThread = useComposerDraftStore((store) =>121 const draftThread = useComposerDraftStore((store) =>
120 draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef),122 draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef),
121 );123 );
apps/web/src/components/ChatView.logic.test.ts+29−44
@@ -1,14 +1,8 @@
1import {1import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, RunId } from "@t3tools/contracts";
2 EnvironmentId,
3 MessageId,
4 ProjectId,
5 ProviderInstanceId,
6 ThreadId,
7 TurnId,
8} from "@t3tools/contracts";
9import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
10 3
11import type { Thread, ThreadShell } from "../types";4import type { Thread } from "../types";
5import { makeThreadFixture } from "../test-fixtures";
12import {6import {
13 MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,7 MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
14 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,8 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
@@ -37,7 +31,7 @@ const threadId = ThreadId.make("thread-1"); · 22 unmodified lines
37const now = "2026-03-29T00:00:00.000Z";31const now = "2026-03-29T00:00:00.000Z";
38 32
39function makeThread(overrides: Partial<Thread> = {}): Thread {33function makeThread(overrides: Partial<Thread> = {}): Thread {
40 return {34 return makeThreadFixture({
41 id: threadId,35 id: threadId,
42 environmentId,36 environmentId,
43 projectId,37 projectId,
@@ -48,27 +42,25 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 4 unmodified lines
48 },42 },
49 runtimeMode: "full-access",43 runtimeMode: "full-access",
50 interactionMode: "default",44 interactionMode: "default",
51 session: null,45 runtime: null,
52 messages: [],46 messages: [],
53 proposedPlans: [],47 proposedPlans: [],
54 activities: [],
55 checkpoints: [],
56 createdAt: now,48 createdAt: now,
57 updatedAt: now,49 updatedAt: now,
58 archivedAt: null,50 archivedAt: null,
59 settledOverride: null,51 settledOverride: null,
60 settledAt: null,52 settledAt: null,
61 deletedAt: null,53 deletedAt: null,
62 latestTurn: null,54 latestRun: null,
63 branch: null,55 branch: null,
64 worktreePath: null,56 worktreePath: null,
65 ...overrides,57 ...overrides,
66 };58 });
67}59}
68 60
69const completedTurn = {61const completedTurn = {
70 turnId: TurnId.make("turn-1"),62 runId: RunId.make("turn-1"),
71 state: "completed" as const,63 status: "completed" as const,
72 requestedAt: now,64 requestedAt: now,
73 startedAt: "2026-03-29T00:00:01.000Z",65 startedAt: "2026-03-29T00:00:01.000Z",
74 completedAt: "2026-03-29T00:00:10.000Z",66 completedAt: "2026-03-29T00:00:10.000Z",
@@ -76,12 +68,10 @@ const completedTurn = { · 1 unmodified line
76};68};
77 69
78const readySession = {70const readySession = {
79 threadId,71 status: "completed" as const,
80 status: "ready" as const,
81 providerName: "codex",72 providerName: "codex",
82 providerInstanceId: ProviderInstanceId.make("codex"),73 providerInstanceId: ProviderInstanceId.make("codex"),
83 runtimeMode: "full-access" as const,74 activeRunId: null,
84 activeTurnId: null,
85 lastError: null,75 lastError: null,
86 updatedAt: "2026-03-29T00:00:10.000Z",76 updatedAt: "2026-03-29T00:00:10.000Z",
87};77};
@@ -527,16 +517,15 @@ describe("startNewThreadForProject", () => { · 439 unmodified lines
527describe("hasServerAcknowledgedLocalDispatch", () => {517describe("hasServerAcknowledgedLocalDispatch", () => {
528 it("does not acknowledge unchanged server state", () => {518 it("does not acknowledge unchanged server state", () => {
529 const localDispatch = createLocalDispatchSnapshot(519 const localDispatch = createLocalDispatchSnapshot(
530 makeThread({ latestTurn: completedTurn, session: readySession }),520 makeThread({ latestRun: completedTurn, runtime: readySession }),
531 );521 );
532 522
533 expect(523 expect(
534 hasServerAcknowledgedLocalDispatch({524 hasServerAcknowledgedLocalDispatch({
535 localDispatch,525 localDispatch,
536 phase: "ready",526 phase: "ready",
537 latestTurn: completedTurn,527 latestRun: completedTurn,
538 latestUserMessageId: localDispatch.latestUserMessageId,528 runtime: readySession,
539 session: readySession,
540 hasPendingApproval: false,529 hasPendingApproval: false,
541 hasPendingUserInput: false,530 hasPendingUserInput: false,
542 threadError: null,531 threadError: null,
@@ -546,11 +535,11 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 3 unmodified lines
546 535
547 it("acknowledges a settled newer turn", () => {536 it("acknowledges a settled newer turn", () => {
548 const localDispatch = createLocalDispatchSnapshot(537 const localDispatch = createLocalDispatchSnapshot(
549 makeThread({ latestTurn: completedTurn, session: readySession }),538 makeThread({ latestRun: completedTurn, runtime: readySession }),
550 );539 );
551 const newerTurn = {540 const newerTurn = {
552 ...completedTurn,541 ...completedTurn,
553 turnId: TurnId.make("turn-2"),542 runId: RunId.make("turn-2"),
554 requestedAt: "2026-03-29T00:01:00.000Z",543 requestedAt: "2026-03-29T00:01:00.000Z",
555 startedAt: "2026-03-29T00:01:01.000Z",544 startedAt: "2026-03-29T00:01:01.000Z",
556 completedAt: "2026-03-29T00:01:30.000Z",545 completedAt: "2026-03-29T00:01:30.000Z",
@@ -560,9 +549,8 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 3 unmodified lines
560 hasServerAcknowledgedLocalDispatch({549 hasServerAcknowledgedLocalDispatch({
561 localDispatch,550 localDispatch,
562 phase: "ready",551 phase: "ready",
563 latestTurn: newerTurn,552 latestRun: newerTurn,
564 latestUserMessageId: localDispatch.latestUserMessageId,553 runtime: { ...readySession, updatedAt: newerTurn.completedAt },
565 session: { ...readySession, updatedAt: newerTurn.completedAt },
566 hasPendingApproval: false,554 hasPendingApproval: false,
567 hasPendingUserInput: false,555 hasPendingUserInput: false,
568 threadError: null,556 threadError: null,
@@ -572,12 +560,12 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 3 unmodified lines
572 560
573 it("waits for the matching running turn before acknowledging", () => {561 it("waits for the matching running turn before acknowledging", () => {
574 const localDispatch = createLocalDispatchSnapshot(562 const localDispatch = createLocalDispatchSnapshot(
575 makeThread({ latestTurn: completedTurn, session: readySession }),563 makeThread({ latestRun: completedTurn, runtime: readySession }),
576 );564 );
577 const runningTurn = {565 const runningTurn = {
578 ...completedTurn,566 ...completedTurn,
579 turnId: TurnId.make("turn-2"),567 runId: RunId.make("turn-2"),
580 state: "running" as const,568 status: "running" as const,
581 requestedAt: "2026-03-29T00:01:00.000Z",569 requestedAt: "2026-03-29T00:01:00.000Z",
582 startedAt: "2026-03-29T00:01:01.000Z",570 startedAt: "2026-03-29T00:01:01.000Z",
583 completedAt: null,571 completedAt: null,
@@ -587,12 +575,11 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 3 unmodified lines
587 hasServerAcknowledgedLocalDispatch({575 hasServerAcknowledgedLocalDispatch({
588 localDispatch,576 localDispatch,
589 phase: "running",577 phase: "running",
590 latestTurn: runningTurn,578 latestRun: runningTurn,
591 latestUserMessageId: localDispatch.latestUserMessageId,579 runtime: {
592 session: {
593 ...readySession,580 ...readySession,
594 status: "running",581 status: "running",
595 activeTurnId: TurnId.make("turn-other"),582 activeRunId: RunId.make("turn-other"),
596 },583 },
597 hasPendingApproval: false,584 hasPendingApproval: false,
598 hasPendingUserInput: false,585 hasPendingUserInput: false,
@@ -603,12 +590,11 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 4 unmodified lines
603 hasServerAcknowledgedLocalDispatch({590 hasServerAcknowledgedLocalDispatch({
604 localDispatch,591 localDispatch,
605 phase: "running",592 phase: "running",
606 latestTurn: runningTurn,593 latestRun: runningTurn,
607 latestUserMessageId: localDispatch.latestUserMessageId,594 runtime: {
608 session: {
609 ...readySession,595 ...readySession,
610 status: "running",596 status: "running",
611 activeTurnId: runningTurn.turnId,597 activeRunId: runningTurn.runId,
612 },598 },
613 hasPendingApproval: false,599 hasPendingApproval: false,
614 hasPendingUserInput: false,600 hasPendingUserInput: false,
@@ -665,9 +651,8 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 50 unmodified lines
665 const common = {651 const common = {
666 localDispatch,652 localDispatch,
667 phase: "ready" as const,653 phase: "ready" as const,
668 latestTurn: null,654 latestRun: null,
669 latestUserMessageId: localDispatch.latestUserMessageId,655 runtime: null,
670 session: null,
671 hasPendingApproval: false,656 hasPendingApproval: false,
672 hasPendingUserInput: false,657 hasPendingUserInput: false,
673 threadError: null,658 threadError: null,
apps/web/src/components/ChatView.logic.ts+75−69
@@ -8,9 +8,11 @@ import { · 7 unmodified lines
8 type ScopedProjectRef,8 type ScopedProjectRef,
9 type ScopedThreadRef,9 type ScopedThreadRef,
10 type ThreadId,10 type ThreadId,
11 type TurnId,11 type RunId,
12} from "@t3tools/contracts";12} from "@t3tools/contracts";
13import { type ChatMessage, type SessionPhase, type Thread, type ThreadShell } from "../types";13import * as DateTime from "effect/DateTime";
14import { presentThread } from "@t3tools/client-runtime/state/shell";
15import { type ChatMessage, type SessionPhase, type Thread } from "../types";
14import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";16import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
15import * as Schema from "effect/Schema";17import * as Schema from "effect/Schema";
16import { appAtomRegistry } from "../rpc/atomRegistry";18import { appAtomRegistry } from "../rpc/atomRegistry";
@@ -70,29 +72,46 @@ export function buildLocalDraftThread( · 53 unmodified lines
70 draftThread: DraftThreadState,72 draftThread: DraftThreadState,
71 fallbackModelSelection: ModelSelection,73 fallbackModelSelection: ModelSelection,
72): Thread {74): Thread {
73 return {75 const timestamp = DateTime.makeUnsafe(draftThread.createdAt);
74 id: threadId,76 return presentThread(draftThread.environmentId, {
75 environmentId: draftThread.environmentId,77 thread: {
76 projectId: draftThread.projectId,78 id: threadId,
77 title: "New thread",79 projectId: draftThread.projectId,
78 modelSelection: fallbackModelSelection,80 title: "New thread",
79 runtimeMode: draftThread.runtimeMode,81 providerInstanceId: fallbackModelSelection.instanceId,
80 interactionMode: draftThread.interactionMode,82 modelSelection: fallbackModelSelection,
81 session: null,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: [],
82 messages: [],105 messages: [],
83 createdAt: draftThread.createdAt,106 plans: [],
84 updatedAt: draftThread.createdAt,107 turnItems: [],
85 archivedAt: null,108 checkpointScopes: [],
86 settledOverride: null,
87 settledAt: null,
88 deletedAt: null,
89 latestTurn: null,
90 branch: draftThread.branch,
91 worktreePath: draftThread.worktreePath,
92 checkpoints: [],109 checkpoints: [],
93 activities: [],110 contextHandoffs: [],
94 proposedPlans: [],111 contextTransfers: [],
95 };112 visibleTurnItems: [],
113 updatedAt: timestamp,
114 });
96}115}
97 116
98export function buildLoadingThreadFromShell(shell: ThreadShell): Thread {117export function buildLoadingThreadFromShell(shell: ThreadShell): Thread {
@@ -353,7 +372,7 @@ export function isBranchMismatchDismissedForSession(key: string | null): boolean · 254 unmodified lines
353 372
354export function threadHasStarted(thread: Thread | null | undefined): boolean {373export function threadHasStarted(thread: Thread | null | undefined): boolean {
355 return Boolean(374 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),
357 );376 );
358}377}
359 378
@@ -377,7 +396,7 @@ export function deriveLockedProvider(input: { · 17 unmodified lines
377 if (!threadHasStarted(input.thread)) {396 if (!threadHasStarted(input.thread)) {
378 return null;397 return null;
379 }398 }
380 const sessionProvider = input.thread?.session?.providerName ?? null;399 const sessionProvider = input.thread?.runtime?.providerName ?? null;
381 if (sessionProvider && isProviderDriverKind(sessionProvider)) {400 if (sessionProvider && isProviderDriverKind(sessionProvider)) {
382 return sessionProvider;401 return sessionProvider;
383 }402 }
@@ -478,41 +497,37 @@ export async function waitForStartedServerThread( · 94 unmodified lines
478export interface LocalDispatchSnapshot {497export interface LocalDispatchSnapshot {
479 startedAt: string;498 startedAt: string;
480 preparingWorktree: boolean;499 preparingWorktree: boolean;
481 latestUserMessageId: ChatMessage["id"] | null;500 latestRunId: RunId | null;
482 latestTurnTurnId: TurnId | null;501 latestRunRequestedAt: string | null;
483 latestTurnRequestedAt: string | null;502 latestRunStartedAt: string | null;
484 latestTurnStartedAt: string | null;503 latestRunCompletedAt: string | null;
485 latestTurnCompletedAt: string | null;504 runtimeStatus: NonNullable<Thread["runtime"]>["status"] | null;
486 sessionStatus: NonNullable<Thread["session"]>["status"] | null;505 runtimeUpdatedAt: string | null;
487 sessionUpdatedAt: string | null;
488}506}
489 507
490export function createLocalDispatchSnapshot(508export function createLocalDispatchSnapshot(
491 activeThread: Thread | undefined,509 activeThread: Thread | undefined,
492 options?: { preparingWorktree?: boolean },510 options?: { preparingWorktree?: boolean },
493): LocalDispatchSnapshot {511): LocalDispatchSnapshot {
494 const latestTurn = activeThread?.latestTurn ?? null;512 const latestRun = activeThread?.latestRun ?? null;
495 const session = activeThread?.session ?? null;513 const runtime = activeThread?.runtime ?? null;
496 const latestUserMessage = activeThread?.messages.findLast((message) => message.role === "user");
497 return {514 return {
498 startedAt: new Date().toISOString(),515 startedAt: new Date().toISOString(),
499 preparingWorktree: Boolean(options?.preparingWorktree),516 preparingWorktree: Boolean(options?.preparingWorktree),
500 latestUserMessageId: latestUserMessage?.id ?? null,517 latestRunId: latestRun?.runId ?? null,
501 latestTurnTurnId: latestTurn?.turnId ?? null,518 latestRunRequestedAt: latestRun?.requestedAt ?? null,
502 latestTurnRequestedAt: latestTurn?.requestedAt ?? null,519 latestRunStartedAt: latestRun?.startedAt ?? null,
503 latestTurnStartedAt: latestTurn?.startedAt ?? null,520 latestRunCompletedAt: latestRun?.completedAt ?? null,
504 latestTurnCompletedAt: latestTurn?.completedAt ?? null,521 runtimeStatus: runtime?.status ?? null,
505 sessionStatus: session?.status ?? null,522 runtimeUpdatedAt: runtime?.updatedAt ?? null,
506 sessionUpdatedAt: session?.updatedAt ?? null,
507 };523 };
508}524}
509 525
510export function hasServerAcknowledgedLocalDispatch(input: {526export function hasServerAcknowledgedLocalDispatch(input: {
511 localDispatch: LocalDispatchSnapshot | null;527 localDispatch: LocalDispatchSnapshot | null;
512 phase: SessionPhase;528 phase: SessionPhase;
513 latestTurn: Thread["latestTurn"] | null;529 latestRun: Thread["latestRun"] | null;
514 latestUserMessageId: ChatMessage["id"] | null;530 runtime: Thread["runtime"] | null;
515 session: Thread["session"] | null;
516 hasPendingApproval: boolean;531 hasPendingApproval: boolean;
517 hasPendingUserInput: boolean;532 hasPendingUserInput: boolean;
518 threadError: string | null | undefined;533 threadError: string | null | undefined;
@@ -524,34 +539,25 @@ export function hasServerAcknowledgedLocalDispatch(input: { · 5 unmodified lines
524 return true;539 return true;
525 }540 }
526 541
527 const latestTurn = input.latestTurn ?? null;542 const latestRun = input.latestRun ?? null;
528 const session = input.session ?? null;543 const runtime = input.runtime ?? null;
529 const latestUserMessageChanged =544 const latestRunChanged =
530 input.localDispatch.latestUserMessageId !== input.latestUserMessageId;545 input.localDispatch.latestRunId !== (latestRun?.runId ?? null) ||
531 const latestTurnChanged =546 input.localDispatch.latestRunRequestedAt !== (latestRun?.requestedAt ?? null) ||
532 input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) ||547 input.localDispatch.latestRunStartedAt !== (latestRun?.startedAt ?? null) ||
533 input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) ||548 input.localDispatch.latestRunCompletedAt !== (latestRun?.completedAt ?? null);
534 input.localDispatch.latestTurnStartedAt !== (latestTurn?.startedAt ?? null) ||
535 input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null);
536 549
537 if (input.phase === "running") {550 if (input.phase === "running") {
538 // Steering adds a user message to the current running turn without551 if (!latestRunChanged) {
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) {
546 return false;552 return false;
547 }553 }
548 if (latestTurn?.startedAt === null || latestTurn === null) {554 if (latestRun?.startedAt === null || latestRun === null) {
549 return false;555 return false;
550 }556 }
551 if (557 if (
552 session?.activeTurnId !== null &&558 runtime?.activeRunId !== null &&
553 session?.activeTurnId !== undefined &&559 runtime?.activeRunId !== undefined &&
554 latestTurn?.turnId !== session.activeTurnId560 latestRun?.runId !== runtime.activeRunId
555 ) {561 ) {
556 return false;562 return false;
557 }563 }
@@ -559,8 +565,8 @@ export function hasServerAcknowledgedLocalDispatch(input: { · 1 unmodified line
559 }565 }
560 566
561 return (567 return (
562 latestTurnChanged ||568 latestRunChanged ||
563 input.localDispatch.sessionStatus !== (session?.status ?? null) ||569 input.localDispatch.runtimeStatus !== (runtime?.status ?? null) ||
564 input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null)570 input.localDispatch.runtimeUpdatedAt !== (runtime?.updatedAt ?? null)
565 );571 );
566}572}
apps/web/src/components/ChatView.tsx+194−267
@@ -1,5 +1,4 @@
1import {1import {
2 type ApprovalRequestId,
3 DEFAULT_MODEL,2 DEFAULT_MODEL,
4 defaultInstanceIdForDriver,3 defaultInstanceIdForDriver,
5 type EnvironmentId,4 type EnvironmentId,
@@ -13,14 +12,15 @@ import { · 7 unmodified lines
13 type ResolvedKeybindingsConfig,12 type ResolvedKeybindingsConfig,
14 type ScopedThreadRef,13 type ScopedThreadRef,
15 type ThreadId,14 type ThreadId,
16 type TurnId,15 type RunId,
16 type RuntimeRequestId,
17 type KeybindingCommand,17 type KeybindingCommand,
18 OrchestrationThreadActivity,
19 ProviderInteractionMode,18 ProviderInteractionMode,
20 ProviderDriverKind,19 ProviderDriverKind,
21 RuntimeMode,20 RuntimeMode,
22 TerminalOpenInput,21 TerminalOpenInput,
23} from "@t3tools/contracts";22} from "@t3tools/contracts";
23import type { ThreadWorkEntry } from "@t3tools/client-runtime/state/shell";
24import {24import {
25 connectionStatusTitle,25 connectionStatusTitle,
26 type EnvironmentConnectionPresentation,26 type EnvironmentConnectionPresentation,
@@ -78,13 +78,14 @@ import { · 51 unmodified lines
78 derivePendingUserInputs,78 derivePendingUserInputs,
79 derivePhase,79 derivePhase,
80 deriveTimelineEntries,80 deriveTimelineEntries,
81 deriveTimelineEntriesFromVisibleTurnItems,
81 deriveActiveWorkStartedAt,82 deriveActiveWorkStartedAt,
82 deriveActivePlanState,83 deriveActivePlanState,
83 findSidebarProposedPlan,84 findSidebarProposedPlan,
84 findLatestProposedPlan,85 findLatestProposedPlan,
85 deriveWorkLogEntries,86 deriveWorkLogEntries,
86 hasActionableProposedPlan,87 hasActionableProposedPlan,
87 isLatestTurnSettled,88 isLatestRunSettled,
88} from "../session-logic";89} from "../session-logic";
89import { type LegendListRef } from "@legendapp/list/react";90import { type LegendListRef } from "@legendapp/list/react";
90import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./chat/timelineScrollAnchoring";91import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./chat/timelineScrollAnchoring";
@@ -217,7 +218,7 @@ import { · 126 unmodified lines
217 useThread,218 useThread,
218 useThreadProposedPlans,219 useThreadProposedPlans,
219 useThreadRefs,220 useThreadRefs,
220 useThreadShell,221 useThreadVisibleTurnItems,
221} from "../state/entities";222} from "../state/entities";
222import { environmentShell } from "../state/shell";223import { environmentShell } from "../state/shell";
223import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";224import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";
@@ -309,7 +310,7 @@ import { useAssetUrls } from "../assets/assetUrls"; · 85 unmodified lines
309 310
310const IMAGE_ONLY_BOOTSTRAP_PROMPT =311const IMAGE_ONLY_BOOTSTRAP_PROMPT =
311 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";312 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
312const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];313const EMPTY_WORK_ENTRIES: ThreadWorkEntry[] = [];
313const EMPTY_PROVIDERS: ServerProvider[] = [];314const EMPTY_PROVIDERS: ServerProvider[] = [];
314const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];315const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
315const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};316const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
@@ -494,10 +495,10 @@ type PersistentTerminalLaunchContext = Pick<TerminalLaunchContext, "cwd" | "work · 178 unmodified lines
494 495
495function useLocalDispatchState(input: {496function useLocalDispatchState(input: {
496 activeThread: Thread | undefined;497 activeThread: Thread | undefined;
497 activeLatestTurn: Thread["latestTurn"] | null;498 activeLatestRun: Thread["latestRun"] | null;
498 phase: SessionPhase;499 phase: SessionPhase;
499 activePendingApproval: ApprovalRequestId | null;500 activePendingApproval: RuntimeRequestId | null;
500 activePendingUserInput: ApprovalRequestId | null;501 activePendingUserInput: RuntimeRequestId | null;
501 threadError: string | null | undefined;502 threadError: string | null | undefined;
502}) {503}) {
503 const [localDispatch, setLocalDispatch] = useState<LocalDispatchSnapshot | null>(null);504 const [localDispatch, setLocalDispatch] = useState<LocalDispatchSnapshot | null>(null);
@@ -513,18 +514,17 @@ function useLocalDispatchState(input: { · 9 unmodified lines
513 hasServerAcknowledgedLocalDispatch({514 hasServerAcknowledgedLocalDispatch({
514 localDispatch,515 localDispatch,
515 phase: input.phase,516 phase: input.phase,
516 latestTurn: input.activeLatestTurn,517 latestRun: input.activeLatestRun,
517 latestUserMessageId,518 runtime: input.activeThread?.runtime ?? null,
518 session: input.activeThread?.session ?? null,
519 hasPendingApproval: input.activePendingApproval !== null,519 hasPendingApproval: input.activePendingApproval !== null,
520 hasPendingUserInput: input.activePendingUserInput !== null,520 hasPendingUserInput: input.activePendingUserInput !== null,
521 threadError: input.threadError,521 threadError: input.threadError,
522 }),522 }),
523 [523 [
524 input.activeLatestTurn,524 input.activeLatestRun,
525 input.activePendingApproval,525 input.activePendingApproval,
526 input.activePendingUserInput,526 input.activePendingUserInput,
527 input.activeThread?.session,527 input.activeThread?.runtime,
528 input.phase,528 input.phase,
529 input.threadError,529 input.threadError,
530 latestUserMessageId,530 latestUserMessageId,
@@ -1202,23 +1202,10 @@ function ChatViewContent(props: ChatViewProps) { · 671 unmodified lines
1202 );1202 );
1203 const composerDraftTarget: ScopedThreadRef | DraftId =1203 const composerDraftTarget: ScopedThreadRef | DraftId =
1204 routeKind === "server" ? routeThreadRef : props.draftId;1204 routeKind === "server" ? routeThreadRef : props.draftId;
1205 const draftThread = useComposerDraftStore((store) =>1205 const serverThread = useThread(routeKind === "server" ? routeThreadRef : null);
1206 routeKind === "server"1206 const serverVisibleTurnItems = useThreadVisibleTurnItems(
1207 ? store.getDraftSessionByRef(routeThreadRef)1207 routeKind === "server" ? routeThreadRef : null,
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],
1220 );1208 );
1221 const activeServerThread = serverThread ?? loadingServerThread;
1222 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);1209 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
1223 const activeThreadLastVisitedAt = useUiStateStore(1210 const activeThreadLastVisitedAt = useUiStateStore(
1224 (store) => store.threadLastVisitedAtById[routeThreadKey],1211 (store) => store.threadLastVisitedAtById[routeThreadKey],
@@ -1293,9 +1280,9 @@ function ChatViewContent(props: ChatViewProps) { · 68 unmodified lines
1293 const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState<string | null>(1280 const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState<string | null>(
1294 null,1281 null,
1295 );1282 );
1296 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);1283 const [respondingRequestIds, setRespondingRequestIds] = useState<RuntimeRequestId[]>([]);
1297 const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState<1284 const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState<
1298 ApprovalRequestId[]1285 RuntimeRequestId[]
1299 >([]);1286 >([]);
1300 const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState<1287 const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState<
1301 Record<string, Record<string, PendingUserInputDraftAnswer>>1288 Record<string, Record<string, PendingUserInputDraftAnswer>>
@@ -1454,7 +1441,7 @@ function ChatViewContent(props: ChatViewProps) { · 152 unmodified lines
1454 const isServerThread = activeServerThread !== null;1441 const isServerThread = activeServerThread !== null;
1455 const activeThread = activeServerThread ?? localDraftThread;1442 const activeThread = activeServerThread ?? localDraftThread;
1456 const threadError = isServerThread1443 const threadError = isServerThread
1457 ? (localServerError ?? activeServerThread?.session?.lastError ?? null)1444 ? (localServerError ?? serverThread?.runtime?.lastError ?? null)
1458 : localDraftError;1445 : localDraftError;
1459 const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;1446 const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
1460 const interactionMode =1447 const interactionMode =
@@ -1576,14 +1563,14 @@ function ChatViewContent(props: ChatViewProps) { · 115 unmodified lines
1576 const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);1563 const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);
1577 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));1564 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));
1578 }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]);1565 }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]);
1579 const activeLatestTurn = activeThread?.latestTurn ?? null;1566 const activeLatestRun = activeThread?.latestRun ?? null;
1580 const sourcePlanThreadRef = useMemo(() => {1567 const sourcePlanThreadRef = useMemo(() => {
1581 const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId;1568 const sourceThreadId = activeLatestRun?.sourcePlanRef?.threadId;
1582 if (!activeThread || !sourceThreadId || sourceThreadId === activeThread.id) {1569 if (!activeThread || !sourceThreadId || sourceThreadId === activeThread.id) {
1583 return null;1570 return null;
1584 }1571 }
1585 return scopeThreadRef(activeThread.environmentId, sourceThreadId);1572 return scopeThreadRef(activeThread.environmentId, sourceThreadId);
1586 }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread]);1573 }, [activeLatestRun?.sourcePlanRef?.threadId, activeThread]);
1587 const sourceThreadProposedPlans = useThreadProposedPlans(sourcePlanThreadRef);1574 const sourceThreadProposedPlans = useThreadProposedPlans(sourcePlanThreadRef);
1588 const threadPlanCatalog = useMemo<ThreadPlanCatalogEntry[]>(() => {1575 const threadPlanCatalog = useMemo<ThreadPlanCatalogEntry[]>(() => {
1589 if (!activeThread) {1576 if (!activeThread) {
@@ -1615,7 +1602,7 @@ function ChatViewContent(props: ChatViewProps) { · 25 unmodified lines
1615 : nextThreadIds;1602 : nextThreadIds;
1616 });1603 });
1617 }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]);1604 }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]);
1618 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);1605 const latestRunSettled = isLatestRunSettled(activeLatestRun, activeThread?.runtime ?? null);
1619 const activeProjectRef = activeThread1606 const activeProjectRef = activeThread
1620 ? scopeProjectRef(activeThread.environmentId, activeThread.projectId)1607 ? scopeProjectRef(activeThread.environmentId, activeThread.projectId)
1621 : null;1608 : null;
@@ -2047,16 +2034,19 @@ function ChatViewContent(props: ChatViewProps) { · 425 unmodified lines
2047 selectedProviderByThreadId ?? threadProvider,2034 selectedProviderByThreadId ?? threadProvider,
2048 );2035 );
2049 const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;2036 const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;
2050 const phase = derivePhase(activeThread?.session ?? null);2037 const phase = derivePhase(activeThread?.runtime ?? null);
2051 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;2038 const threadWorkEntries = activeThread?.workEntries ?? EMPTY_WORK_ENTRIES;
2052 const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]);2039 const workLogEntries = useMemo(
2040 () => deriveWorkLogEntries(threadWorkEntries),
2041 [threadWorkEntries],
2042 );
2053 const pendingApprovals = useMemo(2043 const pendingApprovals = useMemo(
2054 () => derivePendingApprovals(threadActivities),2044 () => derivePendingApprovals(activeThread?.pendingApprovals ?? []),
2055 [threadActivities],2045 [activeThread?.pendingApprovals],
2056 );2046 );
2057 const pendingUserInputs = useMemo(2047 const pendingUserInputs = useMemo(
2058 () => derivePendingUserInputs(threadActivities),2048 () => derivePendingUserInputs(activeThread?.pendingUserInputs ?? []),
2059 [threadActivities],2049 [activeThread?.pendingUserInputs],
2060 );2050 );
2061 const activePendingUserInput = pendingUserInputs[0] ?? null;2051 const activePendingUserInput = pendingUserInputs[0] ?? null;
2062 const activePendingDraftAnswers = useMemo(2052 const activePendingDraftAnswers = useMemo(
@@ -2089,36 +2079,37 @@ function ChatViewContent(props: ChatViewProps) { · 26 unmodified lines
2089 [activePendingDraftAnswers, activePendingUserInput],2079 [activePendingDraftAnswers, activePendingUserInput],
2090 );2080 );
2091 const activePendingIsResponding = activePendingUserInput2081 const activePendingIsResponding = activePendingUserInput
2092 ? respondingUserInputRequestIds.includes(activePendingUserInput.requestId)2082 ? activePendingUserInput.responseCapability !== "live" ||
2083 respondingUserInputRequestIds.includes(activePendingUserInput.requestId)
2093 : false;2084 : false;
2094 const activeProposedPlan = useMemo(() => {2085 const activeProposedPlan = useMemo(() => {
2095 if (!latestTurnSettled) {2086 if (!latestRunSettled) {
2096 return null;2087 return null;
2097 }2088 }
2098 return findLatestProposedPlan(2089 return findLatestProposedPlan(
2099 activeThread?.proposedPlans ?? [],2090 activeThread?.proposedPlans ?? [],
2100 activeLatestTurn?.turnId ?? null,2091 activeLatestRun?.runId ?? null,
2101 );2092 );
2102 }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]);2093 }, [activeLatestRun?.runId, activeThread?.proposedPlans, latestRunSettled]);
2103 const sidebarProposedPlan = useMemo(2094 const sidebarProposedPlan = useMemo(
2104 () =>2095 () =>
2105 findSidebarProposedPlan({2096 findSidebarProposedPlan({
2106 threads: threadPlanCatalog,2097 threads: threadPlanCatalog,
2107 latestTurn: activeLatestTurn,2098 latestRun: activeLatestRun,
2108 latestTurnSettled,2099 latestRunSettled,
2109 threadId: activeThread?.id ?? null,2100 threadId: activeThread?.id ?? null,
2110 }),2101 }),
2111 [activeLatestTurn, activeThread?.id, latestTurnSettled, threadPlanCatalog],2102 [activeLatestRun, activeThread?.id, latestRunSettled, threadPlanCatalog],
2112 );2103 );
2113 const activePlan = useMemo(2104 const activePlan = useMemo(
2114 () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined),2105 () => deriveActivePlanState(activeThread?.todoPlans ?? [], activeLatestRun?.runId ?? undefined),
2115 [activeLatestTurn?.turnId, threadActivities],2106 [activeLatestRun?.runId, activeThread?.todoPlans],
2116 );2107 );
2117 const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks";2108 const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks";
2118 const showPlanFollowUpPrompt =2109 const showPlanFollowUpPrompt =
2119 pendingUserInputs.length === 0 &&2110 pendingUserInputs.length === 0 &&
2120 interactionMode === "plan" &&2111 interactionMode === "plan" &&
2121 latestTurnSettled &&2112 latestRunSettled &&
2122 hasActionableProposedPlan(activeProposedPlan);2113 hasActionableProposedPlan(activeProposedPlan);
2123 const activePendingApproval = pendingApprovals[0] ?? null;2114 const activePendingApproval = pendingApprovals[0] ?? null;
2124 const {2115 const {
@@ -2129,7 +2120,7 @@ function ChatViewContent(props: ChatViewProps) { · 4 unmodified lines
2129 isSendBusy,2120 isSendBusy,
2130 } = useLocalDispatchState({2121 } = useLocalDispatchState({
2131 activeThread,2122 activeThread,
2132 activeLatestTurn,2123 activeLatestRun,
2133 phase,2124 phase,
2134 activePendingApproval: activePendingApproval?.requestId ?? null,2125 activePendingApproval: activePendingApproval?.requestId ?? null,
2135 activePendingUserInput: activePendingUserInput?.requestId ?? null,2126 activePendingUserInput: activePendingUserInput?.requestId ?? null,
@@ -2137,8 +2128,8 @@ function ChatViewContent(props: ChatViewProps) { · 1 unmodified line
2137 });2128 });
2138 const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;2129 const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
2139 const activeWorkStartedAt = deriveActiveWorkStartedAt(2130 const activeWorkStartedAt = deriveActiveWorkStartedAt(
2140 activeLatestTurn,2131 activeLatestRun,
2141 activeThread?.session ?? null,2132 activeThread?.runtime ?? null,
2142 localDispatchStartedAt,2133 localDispatchStartedAt,
2143 );2134 );
2144 useEffect(() => {2135 useEffect(() => {
@@ -2202,7 +2193,17 @@ function ChatViewContent(props: ChatViewProps) { · 57 unmodified lines
2202 });2193 });
2203 }, []);2194 }, []);
2204 const serverMessages = activeThread?.messages;2195 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(() => {
2206 const attachmentIds = new Set<string>();2207 const attachmentIds = new Set<string>();
2207 for (const message of serverMessages ?? []) {2208 for (const message of serverMessages ?? []) {
2208 for (const attachment of message.attachments ?? []) {2209 for (const attachment of message.attachments ?? []) {
@@ -2211,6 +2212,7 @@ function ChatViewContent(props: ChatViewProps) { · 2 unmodified lines
2211 }2212 }
2212 return [...attachmentIds];2213 return [...attachmentIds];
2213 }, [serverMessages]);2214 }, [serverMessages]);
2215 const serverAttachmentIds = isServerThread ? committedServerAttachmentIds : draftAttachmentIds;
2214 const serverAttachmentResources = useMemo(2216 const serverAttachmentResources = useMemo(
2215 () =>2217 () =>
2216 serverAttachmentIds.map((attachmentId) => ({2218 serverAttachmentIds.map((attachmentId) => ({
@@ -2381,22 +2383,22 @@ function ChatViewContent(props: ChatViewProps) { · 164 unmodified lines
2381 }2383 }
2382 return [...serverMessagesWithPreviewHandoff, ...pendingMessages];2384 return [...serverMessagesWithPreviewHandoff, ...pendingMessages];
2383 }, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]);2385 }, [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(
2385 () =>2396 () =>
2386 deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries),2397 deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries),
2387 [activeThread?.proposedPlans, timelineMessages, workLogEntries],2398 [activeThread?.proposedPlans, timelineMessages, workLogEntries],
2388 );2399 );
2389 const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null);2400 const timelineEntries = isServerThread ? serverTimelineEntries : draftTimelineEntries;
2390 const draftHeroDockRequested =2401 const { turnDiffSummaries, inferredCheckpointTurnCountByRunId } =
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 useTurnDiffSummaries(activeThread);2402 useTurnDiffSummaries(activeThread);
2401 const turnDiffSummaryByAssistantMessageId = useMemo(() => {2403 const turnDiffSummaryByAssistantMessageId = useMemo(() => {
2402 const byMessageId = new Map<MessageId, TurnDiffSummary>();2404 const byMessageId = new Map<MessageId, TurnDiffSummary>();
@@ -2427,7 +2429,7 @@ function ChatViewContent(props: ChatViewProps) { · 24 unmodified lines
2427 continue;2429 continue;
2428 }2430 }
2429 const turnCount =2431 const turnCount =
2430 summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId];2432 summary.checkpointTurnCount ?? inferredCheckpointTurnCountByRunId[summary.runId];
2431 if (typeof turnCount !== "number") {2433 if (typeof turnCount !== "number") {
2432 break;2434 break;
2433 }2435 }
@@ -2437,7 +2439,7 @@ function ChatViewContent(props: ChatViewProps) { · 3 unmodified lines
2437 }2439 }
2438 2440
2439 return byUserMessageId;2441 return byUserMessageId;
2440 }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);2442 }, [inferredCheckpointTurnCountByRunId, timelineEntries, turnDiffSummaryByAssistantMessageId]);
2441 2443
2442 const gitCwd = activeProject2444 const gitCwd = activeProject
2443 ? projectScriptCwd({2445 ? projectScriptCwd({
@@ -2465,7 +2467,7 @@ function ChatViewContent(props: ChatViewProps) { · 21 unmodified lines
2465 ?.instanceId ?? null;2467 ?.instanceId ?? null;
2466 const activeProviderInstanceId =2468 const activeProviderInstanceId =
2467 selectedProviderInstanceId ??2469 selectedProviderInstanceId ??
2468 activeThread?.session?.providerInstanceId ??2470 activeThread?.runtime?.providerInstanceId ??
2469 activeThread?.modelSelection.instanceId ??2471 activeThread?.modelSelection.instanceId ??
2470 activeProject?.defaultModelSelection?.instanceId ??2472 activeProject?.defaultModelSelection?.instanceId ??
2471 null;2473 null;
@@ -2544,9 +2546,7 @@ function ChatViewContent(props: ChatViewProps) { · 72 unmodified lines
2544 }, [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen]);2546 }, [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen]);
2545 2547
2546 const envLocked = Boolean(2548 const envLocked = Boolean(
2547 activeThread &&2549 activeThread && (activeThread.messages.length > 0 || activeThread.runtime !== null),
2548 (activeThread.messages.length > 0 ||
2549 (activeThread.session !== null && activeThread.session.status !== "stopped")),
2550 );2550 );
2551 2551
2552 // Handle environment change for draft threads. When the user picks a2552 // Handle environment change for draft threads. When the user picks a
@@ -3092,8 +3092,8 @@ function ChatViewContent(props: ChatViewProps) { · 539 unmodified lines
3092 }, [handleInteractionModeChange, interactionMode]);3092 }, [handleInteractionModeChange, interactionMode]);
3093 const dismissPlanSidebarForCurrentTurn = useCallback(() => {3093 const dismissPlanSidebarForCurrentTurn = useCallback(() => {
3094 planSidebarDismissedForTurnRef.current =3094 planSidebarDismissedForTurnRef.current =
3095 activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";3095 activePlan?.runId ?? sidebarProposedPlan?.runId ?? "__dismissed__";
3096 }, [activePlan?.turnId, sidebarProposedPlan?.turnId]);3096 }, [activePlan?.runId, sidebarProposedPlan?.runId]);
3097 const togglePlanSidebar = useCallback(() => {3097 const togglePlanSidebar = useCallback(() => {
3098 if (!activeThreadRef) return;3098 if (!activeThreadRef) return;
3099 if (planSidebarOpen) {3099 if (planSidebarOpen) {
@@ -3842,20 +3842,20 @@ function ChatViewContent(props: ChatViewProps) { · 742 unmodified lines
3842 if (!autoOpenPlanSidebar) return;3842 if (!autoOpenPlanSidebar) return;
3843 if (!activePlan) return;3843 if (!activePlan) return;
3844 if (planSidebarOpen) return;3844 if (planSidebarOpen) return;
3845 const latestTurnId = activeLatestTurn?.turnId ?? null;3845 const latestRunId = activeLatestRun?.runId ?? null;
3846 if (latestTurnId && activePlan.turnId !== latestTurnId) return;3846 if (latestRunId && activePlan.runId !== latestRunId) return;
3847 const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";3847 const turnKey = activePlan.runId ?? sidebarProposedPlan?.runId ?? "__dismissed__";
3848 if (planSidebarDismissedForTurnRef.current === turnKey) return;3848 if (planSidebarDismissedForTurnRef.current === turnKey) return;
3849 if (activeThreadRef) {3849 if (activeThreadRef) {
3850 useRightPanelStore.getState().open(activeThreadRef, "plan");3850 useRightPanelStore.getState().open(activeThreadRef, "plan");
3851 }3851 }
3852 }, [3852 }, [
3853 activePlan,3853 activePlan,
3854 activeLatestTurn?.turnId,3854 activeLatestRun?.runId,
3855 activeThreadRef,3855 activeThreadRef,
3856 autoOpenPlanSidebar,3856 autoOpenPlanSidebar,
3857 planSidebarOpen,3857 planSidebarOpen,
3858 sidebarProposedPlan?.turnId,3858 sidebarProposedPlan?.runId,
3859 ]);3859 ]);
3860 3860
3861 useEffect(() => {3861 useEffect(() => {
@@ -4753,7 +4753,7 @@ function ChatViewContent(props: ChatViewProps) { · 891 unmodified lines
4753 role: "user",4753 role: "user",
4754 text: outgoingMessageText,4754 text: outgoingMessageText,
4755 ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}),4755 ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}),
4756 turnId: null,4756 runId: null,
4757 createdAt: messageCreatedAt,4757 createdAt: messageCreatedAt,
4758 updatedAt: messageCreatedAt,4758 updatedAt: messageCreatedAt,
4759 streaming: false,4759 streaming: false,
@@ -4966,8 +4966,13 @@ function ChatViewContent(props: ChatViewProps) { · 206 unmodified lines
4966 };4966 };
4967 4967
4968 const onRespondToApproval = useCallback(4968 const onRespondToApproval = useCallback(
4969 async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => {4969 async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => {
4970 if (!activeThreadId) return;4970 if (!activeThreadId) return;
4971 if (
4972 pendingApprovals.find((approval) => approval.requestId === requestId)
4973 ?.responseCapability !== "live"
4974 )
4975 return;
4971 4976
4972 setRespondingRequestIds((existing) =>4977 setRespondingRequestIds((existing) =>
4973 existing.includes(requestId) ? existing : [...existing, requestId],4978 existing.includes(requestId) ? existing : [...existing, requestId],
@@ -4990,12 +4995,17 @@ function ChatViewContent(props: ChatViewProps) { · 16 unmodified lines
4990 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));4995 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
4991 return result;4996 return result;
4992 },4997 },
4993 [activeThreadId, environmentId, respondToThreadApproval, setThreadError],4998 [activeThreadId, environmentId, pendingApprovals, respondToThreadApproval, setThreadError],
4994 );4999 );
4995 5000
4996 const onRespondToUserInput = useCallback(5001 const onRespondToUserInput = useCallback(
4997 async (requestId: ApprovalRequestId, answers: Record<string, unknown>) => {5002 async (requestId: RuntimeRequestId, answers: Record<string, unknown>) => {
4998 if (!activeThreadId) return;5003 if (!activeThreadId) return;
5004 if (
5005 pendingUserInputs.find((input) => input.requestId === requestId)?.responseCapability !==
5006 "live"
5007 )
5008 return;
4999 5009
5000 setRespondingUserInputRequestIds((existing) =>5010 setRespondingUserInputRequestIds((existing) =>
5001 existing.includes(requestId) ? existing : [...existing, requestId],5011 existing.includes(requestId) ? existing : [...existing, requestId],
@@ -5018,7 +5028,7 @@ function ChatViewContent(props: ChatViewProps) { · 16 unmodified lines
5018 setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId));5028 setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId));
5019 return result;5029 return result;
5020 },5030 },
5021 [activeThreadId, environmentId, respondToThreadUserInput, setThreadError],5031 [activeThreadId, environmentId, pendingUserInputs, respondToThreadUserInput, setThreadError],
5022 );5032 );
5023 5033
5024 const setActivePendingUserInputQuestionIndex = useCallback(5034 const setActivePendingUserInputQuestionIndex = useCallback(
@@ -5102,7 +5112,11 @@ function ChatViewContent(props: ChatViewProps) { · 77 unmodified lines
5102 );5112 );
5103 5113
5104 const onAdvanceActivePendingUserInput = useCallback(() => {5114 const onAdvanceActivePendingUserInput = useCallback(() => {
5105 if (!activePendingUserInput || !activePendingProgress) {5115 if (
5116 !activePendingUserInput ||
5117 activePendingUserInput.responseCapability !== "live" ||
5118 !activePendingProgress
5119 ) {
5106 return;5120 return;
5107 }5121 }
5108 if (activePendingProgress.isLastQuestion) {5122 if (activePendingProgress.isLastQuestion) {
@@ -5196,7 +5210,7 @@ function ChatViewContent(props: ChatViewProps) { · 87 unmodified lines
5196 id: messageIdForSend,5210 id: messageIdForSend,
5197 role: "user",5211 role: "user",
5198 text: outgoingMessageText,5212 text: outgoingMessageText,
5199 turnId: null,5213 runId: null,
5200 createdAt: messageCreatedAt,5214 createdAt: messageCreatedAt,
5201 updatedAt: messageCreatedAt,5215 updatedAt: messageCreatedAt,
5202 streaming: false,5216 streaming: false,
@@ -5466,9 +5480,9 @@ function ChatViewContent(props: ChatViewProps) { · 263 unmodified lines
5466 }5480 }
5467 const reason = getStartedThreadModelChangeBlockReason({5481 const reason = getStartedThreadModelChangeBlockReason({
5468 providers: providerStatuses,5482 providers: providerStatuses,
5469 hasStartedSession: activeThread.session !== null,5483 hasStartedSession: activeThread.runtime !== null,
5470 currentModelSelection: activeThread.modelSelection,5484 currentModelSelection: activeThread.modelSelection,
5471 currentProviderInstanceId: activeThread.session?.providerInstanceId ?? null,5485 currentProviderInstanceId: activeThread.runtime?.providerInstanceId ?? null,
5472 nextModelSelection: { instanceId, model },5486 nextModelSelection: { instanceId, model },
5473 });5487 });
5474 return reason ? `${reason.description} Start a new thread to use this model.` : null;5488 return reason ? `${reason.description} Start a new thread to use this model.` : null;
@@ -5492,9 +5506,9 @@ function ChatViewContent(props: ChatViewProps) { · 17 unmodified lines
5492 scheduleComposerFocus();5506 scheduleComposerFocus();
5493 return;5507 return;
5494 }5508 }
5495 if (lockedProvider !== null && activeThread.session?.providerInstanceId) {5509 if (lockedProvider !== null && activeThread.runtime?.providerInstanceId) {
5496 const currentEntry = providerStatuses.find(5510 const currentEntry = providerStatuses.find(
5497 (snapshot) => snapshot.instanceId === activeThread.session?.providerInstanceId,5511 (snapshot) => snapshot.instanceId === activeThread.runtime?.providerInstanceId,
5498 );5512 );
5499 if (5513 if (
5500 currentEntry?.continuation?.groupKey &&5514 currentEntry?.continuation?.groupKey &&
@@ -5521,9 +5535,9 @@ function ChatViewContent(props: ChatViewProps) { · 20 unmodified lines
5521 };5535 };
5522 const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({5536 const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({
5523 providers: providerStatuses,5537 providers: providerStatuses,
5524 hasStartedSession: activeThread.session !== null,5538 hasStartedSession: activeThread.runtime !== null,
5525 currentModelSelection: activeThread.modelSelection,5539 currentModelSelection: activeThread.modelSelection,
5526 currentProviderInstanceId: activeThread.session?.providerInstanceId ?? null,5540 currentProviderInstanceId: activeThread.runtime?.providerInstanceId ?? null,
5527 nextModelSelection,5541 nextModelSelection,
5528 });5542 });
5529 if (modelChangeBlockReason) {5543 if (modelChangeBlockReason) {
@@ -5603,9 +5617,9 @@ function ChatViewContent(props: ChatViewProps) { · 73 unmodified lines
5603 setExpandedImage(preview);5617 setExpandedImage(preview);
5604 }, []);5618 }, []);
5605 const onOpenTurnDiff = useCallback(5619 const onOpenTurnDiff = useCallback(
5606 (turnId: TurnId, filePath?: string) => {5620 (runId: RunId, filePath?: string) => {
5607 if (!isServerThread || !activeThreadRef) return;5621 if (!isServerThread || !activeThreadRef) return;
5608 useDiffPanelStore.getState().selectTurn(activeThreadRef, turnId, filePath);5622 useDiffPanelStore.getState().selectTurn(activeThreadRef, runId, filePath);
5609 useRightPanelStore.getState().open(activeThreadRef, "diff");5623 useRightPanelStore.getState().open(activeThreadRef, "diff");
5610 onDiffPanelOpen?.();5624 onDiffPanelOpen?.();
5611 },5625 },
@@ -5800,16 +5814,11 @@ function ChatViewContent(props: ChatViewProps) { · 188 unmodified lines
5800 <MessagesTimeline5814 <MessagesTimeline
5801 key={activeThread.id}5815 key={activeThread.id}
5802 isWorking={isWorking}5816 isWorking={isWorking}
5803 activeTurnInProgress={isWorking || !latestTurnSettled}5817 activeTurnInProgress={isWorking || !latestRunSettled}
5804 activeTurnStartedAt={activeWorkStartedAt}5818 activeTurnStartedAt={activeWorkStartedAt}
5805 listRef={legendListRef}5819 listRef={legendListRef}
5806 timelineEntries={timelineEntries}5820 timelineEntries={timelineEntries}
5807 latestTurn={activeLatestTurn}5821 latestRun={activeLatestRun}
5808 runningTurnId={
5809 activeThread.session?.status === "running"
5810 ? activeThread.session.activeTurnId
5811 : null
5812 }
5813 turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}5822 turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}
5814 activeThreadEnvironmentId={activeThread.environmentId}5823 activeThreadEnvironmentId={activeThread.environmentId}
5815 routeThreadKey={routeThreadKey}5824 routeThreadKey={routeThreadKey}
@@ -5867,168 +5876,86 @@ function ChatViewContent(props: ChatViewProps) { · 51 unmodified lines
5867 ref={attachDraftHeroTransitionGroupRef}5876 ref={attachDraftHeroTransitionGroupRef}
5868 className="chat-composer-horizontal-inset w-full"5877 className="chat-composer-horizontal-inset w-full"
5869 >5878 >
5870 <div className="pointer-events-auto relative z-10">5879 <div className="relative mx-auto h-full w-full max-w-208 overflow-clip rounded-t-[20px]">
5871 {isDraftHeroState ? (5880 <div className="chat-composer-shared-blur absolute -inset-8" />
5872 <div className="absolute inset-x-0 bottom-full z-0">5881 </div>
5873 <div5882 </div>
5874 className="pb-8"5883 <div className="chat-composer-horizontal-inset">
5875 style={5884 <div className="pointer-events-auto relative z-10 isolate">
5876 forceExpandedMobileComposer5885 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
5877 ? {5886 <div className="relative z-10">
5878 viewTransitionName: MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME,5887 <ChatComposer
5879 }5888 composerRef={composerRef}
5880 : undefined5889 composerDraftTarget={composerDraftTarget}
5881 }5890 environmentId={environmentId}
5882 >5891 routeKind={routeKind}
5883 <DraftHeroHeadline5892 routeThreadRef={routeThreadRef}
5884 activeProjectRef={activeProjectRef}5893 draftId={draftId}
5885 activeProjectTitle={activeProject?.title ?? null}5894 activeThreadId={activeThreadId}
5886 />5895 activeThreadEnvironmentId={activeThread?.environmentId}
5887 </div>5896 activeThread={activeThread}
5888 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />5897 isServerThread={isServerThread}
5889 </div>5898 isLocalDraftThread={isLocalDraftThread}
5890 ) : (5899 phase={phase}
5891 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />5900 isConnecting={isConnecting}
5892 )}5901 isSendBusy={isSendBusy}
5893 {threadSyncPhase && !activeEnvironmentUnavailable ? (5902 isPreparingWorktree={isPreparingWorktree}
5894 <ThreadSyncStatusPill phase={threadSyncPhase} />5903 environmentUnavailable={activeEnvironmentUnavailableState}
5895 ) : null}5904 activePendingApproval={activePendingApproval}
5896 <div5905 pendingApprovals={pendingApprovals}
5897 className="relative"5906 pendingUserInputs={pendingUserInputs}
5898 style={5907 activePendingProgress={activePendingProgress}
5899 forceExpandedMobileComposer5908 activePendingResolvedAnswers={activePendingResolvedAnswers}
5900 ? { viewTransitionName: MOBILE_COMPOSER_VIEW_TRANSITION_NAME }5909 activePendingIsResponding={activePendingIsResponding}
5901 : undefined5910 activePendingDraftAnswers={activePendingDraftAnswers}
5902 }5911 activePendingQuestionIndex={activePendingQuestionIndex}
5903 >5912 respondingRequestIds={respondingRequestIds}
5904 <div5913 showPlanFollowUpPrompt={showPlanFollowUpPrompt}
5905 className={cn(5914 activeProposedPlan={activeProposedPlan}
5906 "chat-composer-glass-shell relative mx-auto w-full max-w-3xl",5915 activePlan={activePlan}
5907 showComposerContextStrip && "chat-composer-glass-shell-with-context",5916 sidebarProposedPlan={sidebarProposedPlan}
5908 )}5917 planSidebarLabel={planSidebarLabel}
5909 >5918 planSidebarOpen={planSidebarOpen}
5910 <div className="chat-composer-glass-host relative z-10 w-full rounded-[22px]">5919 runtimeMode={runtimeMode}
5911 <div ref={attachDraftHeroComposerAnchorRef} className="relative z-10">5920 interactionMode={interactionMode}
5912 <ChatComposer5921 lockedProvider={lockedProvider}
5913 composerRef={composerRef}5922 providerStatuses={providerStatuses as ServerProvider[]}
5914 composerDraftTarget={composerDraftTarget}5923 activeProjectDefaultModelSelection={activeProject?.defaultModelSelection}
5915 environmentId={environmentId}5924 activeThreadModelSelection={activeThread?.modelSelection}
5916 routeKind={routeKind}5925 activeThreadWorkEntries={activeThread?.workEntries}
5917 routeThreadRef={routeThreadRef}5926 resolvedTheme={resolvedTheme}
5918 draftId={draftId}5927 settings={settings}
5919 activeThreadId={activeThreadId}5928 keybindings={keybindings}
5920 activeThreadEnvironmentId={activeThread?.environmentId}5929 terminalOpen={Boolean(terminalUiState.terminalOpen)}
5921 activeThread={activeThread}5930 gitCwd={gitCwd}
5922 isServerThread={isServerThread}5931 promptRef={promptRef}
5923 isLocalDraftThread={isLocalDraftThread}5932 composerImagesRef={composerImagesRef}
5924 forceExpandedOnMobile={forceExpandedMobileComposer && isDraftHeroState}5933 composerTerminalContextsRef={composerTerminalContextsRef}
5925 projectSelectionRequired={isLocalDraftThread && activeProject === null}5934 composerElementContextsRef={composerElementContextsRef}
5926 phase={phase}5935 shouldAutoScrollRef={isAtEndRef}
5927 isConnecting={isConnecting}5936 scheduleStickToBottom={scrollToEnd}
5928 isSendBusy={isSendBusy}5937 onSend={onSend}
5929 sendDisabledReason={threadDetailLoading ? "Messages loading" : null}5938 onInterrupt={onInterrupt}
5930 isPreparingWorktree={isPreparingWorktree}5939 onImplementPlanInNewThread={onImplementPlanInNewThread}
5931 environmentUnavailable={activeEnvironmentUnavailableState}5940 onRespondToApproval={onRespondToApproval}
5932 activePendingApproval={activePendingApproval}5941 onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption}
5933 pendingApprovals={pendingApprovals}5942 onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput}
5934 pendingUserInputs={pendingUserInputs}5943 onPreviousActivePendingUserInputQuestion={
5935 activePendingProgress={activePendingProgress}5944 onPreviousActivePendingUserInputQuestion
5936 activePendingResolvedAnswers={activePendingResolvedAnswers}5945 }
5937 activePendingIsResponding={activePendingIsResponding}5946 onChangeActivePendingUserInputCustomAnswer={
5938 activePendingDraftAnswers={activePendingDraftAnswers}5947 onChangeActivePendingUserInputCustomAnswer
5939 activePendingQuestionIndex={activePendingQuestionIndex}5948 }
5940 respondingRequestIds={respondingRequestIds}5949 onProviderModelSelect={onProviderModelSelect}
5941 showPlanFollowUpPrompt={showPlanFollowUpPrompt}5950 getModelDisabledReason={getModelDisabledReason}
5942 activeProposedPlan={activeProposedPlan}5951 toggleInteractionMode={toggleInteractionMode}
5943 activePlan={activePlan as { turnId?: TurnId } | null}5952 handleRuntimeModeChange={handleRuntimeModeChange}
5944 sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null}5953 handleInteractionModeChange={handleInteractionModeChange}
5945 planSidebarLabel={planSidebarLabel}5954 togglePlanSidebar={togglePlanSidebar}
5946 planSidebarOpen={planSidebarOpen}5955 focusComposer={focusComposer}
5947 runtimeMode={runtimeMode}5956 scheduleComposerFocus={scheduleComposerFocus}
5948 interactionMode={interactionMode}5957 setThreadError={setThreadError}
5949 lockedProvider={lockedProvider}5958 onExpandImage={onExpandTimelineImage}
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)]"
6032 />5959 />
6033 </div>5960 </div>
6034 </div>5961 </div>
apps/web/src/components/CommandPalette.logic.test.ts+5−6
@@ -1,6 +1,7 @@
1import { describe, expect, it, vi } from "vite-plus/test";1import { describe, expect, it, vi } from "vite-plus/test";
2import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";2import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
3import type { Thread } from "../types";3import type { Thread } from "../types";
4import { makeThreadFixture } from "../test-fixtures";
4import {5import {
5 buildBrowseGroups,6 buildBrowseGroups,
6 buildThreadActionItems,7 buildThreadActionItems,
@@ -112,7 +113,7 @@ const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); · 105 unmodified lines
112const PROJECT_ID = ProjectId.make("project-1");113const PROJECT_ID = ProjectId.make("project-1");
113 114
114function makeThread(overrides: Partial<Thread> = {}): Thread {115function makeThread(overrides: Partial<Thread> = {}): Thread {
115 return {116 return makeThreadFixture({
116 id: ThreadId.make("thread-1"),117 id: ThreadId.make("thread-1"),
117 environmentId: LOCAL_ENVIRONMENT_ID,118 environmentId: LOCAL_ENVIRONMENT_ID,
118 projectId: PROJECT_ID,119 projectId: PROJECT_ID,
@@ -120,7 +121,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 1 unmodified line
120 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },121 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
121 runtimeMode: "full-access",122 runtimeMode: "full-access",
122 interactionMode: "default",123 interactionMode: "default",
123 session: null,124 runtime: null,
124 messages: [],125 messages: [],
125 proposedPlans: [],126 proposedPlans: [],
126 createdAt: "2026-03-01T00:00:00.000Z",127 createdAt: "2026-03-01T00:00:00.000Z",
@@ -129,13 +130,11 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 2 unmodified lines
129 settledAt: null,130 settledAt: null,
130 deletedAt: null,131 deletedAt: null,
131 updatedAt: "2026-03-01T00:00:00.000Z",132 updatedAt: "2026-03-01T00:00:00.000Z",
132 latestTurn: null,133 latestRun: null,
133 branch: null,134 branch: null,
134 worktreePath: null,135 worktreePath: null,
135 checkpoints: [],
136 activities: [],
137 ...overrides,136 ...overrides,
138 };137 });
139}138}
140 139
141describe("buildThreadActionItems", () => {140describe("buildThreadActionItems", () => {
apps/web/src/components/DiffPanel.tsx+40−45
@@ -5,7 +5,7 @@ import { · 4 unmodified lines
5 squashAtomCommandFailure,5 squashAtomCommandFailure,
6} from "@t3tools/client-runtime/state/runtime";6} from "@t3tools/client-runtime/state/runtime";
7import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";7import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
8import type { ScopedThreadRef, TurnId } from "@t3tools/contracts";8import type { ScopedThreadRef, RunId } from "@t3tools/contracts";
9import {9import {
10 ArrowRightIcon,10 ArrowRightIcon,
11 CheckIcon,11 CheckIcon,
@@ -246,55 +246,55 @@ export default function DiffPanel({ · 234 unmodified lines
246 ),246 ),
247 );247 );
248 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;248 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
249 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =249 const { turnDiffSummaries, inferredCheckpointTurnCountByRunId } =
250 useTurnDiffSummaries(activeThread);250 useTurnDiffSummaries(activeThread);
251 const orderedTurnDiffSummaries = useMemo(251 const orderedTurnDiffSummaries = useMemo(
252 () =>252 () =>
253 [...turnDiffSummaries].toSorted((left, right) => {253 [...turnDiffSummaries].toSorted((left, right) => {
254 const leftTurnCount =254 const leftTurnCount =
255 left.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[left.turnId] ?? 0;255 left.checkpointTurnCount ?? inferredCheckpointTurnCountByRunId[left.runId] ?? 0;
256 const rightTurnCount =256 const rightTurnCount =
257 right.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[right.turnId] ?? 0;257 right.checkpointTurnCount ?? inferredCheckpointTurnCountByRunId[right.runId] ?? 0;
258 if (leftTurnCount !== rightTurnCount) {258 if (leftTurnCount !== rightTurnCount) {
259 return rightTurnCount - leftTurnCount;259 return rightTurnCount - leftTurnCount;
260 }260 }
261 return right.completedAt.localeCompare(left.completedAt);261 return right.completedAt.localeCompare(left.completedAt);
262 }),262 }),
263 [inferredCheckpointTurnCountByTurnId, turnDiffSummaries],263 [inferredCheckpointTurnCountByRunId, turnDiffSummaries],
264 );264 );
265 265
266 useEffect(() => {266 useEffect(() => {
267 if (!routeThreadRef || diffSelection.kind !== "turn") return;267 if (!routeThreadRef || diffSelection.kind !== "turn") return;
268 useDiffPanelStore.getState().reconcileTurnSelection(268 useDiffPanelStore.getState().reconcileTurnSelection(
269 routeThreadRef,269 routeThreadRef,
270 orderedTurnDiffSummaries.map((summary) => summary.turnId),270 orderedTurnDiffSummaries.map((summary) => summary.runId),
271 );271 );
272 }, [diffSelection, orderedTurnDiffSummaries, routeThreadRef]);272 }, [diffSelection, orderedTurnDiffSummaries, routeThreadRef]);
273 273
274 const selectedTurnId = diffSelection.kind === "turn" ? diffSelection.turnId : null;274 const selectedRunId = diffSelection.kind === "turn" ? diffSelection.turnId : null;
275 const selectedGitScope = diffSelection.kind === "unstaged" ? "unstaged" : "branch";275 const selectedGitScope = diffSelection.kind === "unstaged" ? "unstaged" : "branch";
276 const selectedBaseRef = diffSelection.kind === "branch" ? diffSelection.baseRef : null;276 const selectedBaseRef = diffSelection.kind === "branch" ? diffSelection.baseRef : null;
277 const selectedFilePath = diffSelection.kind === "turn" ? diffSelection.filePath : null;277 const selectedFilePath = diffSelection.kind === "turn" ? diffSelection.filePath : null;
278 const selectedFileRevealRequestId =278 const selectedFileRevealRequestId =
279 diffSelection.kind === "turn" ? diffSelection.revealRequestId : 0;279 diffSelection.kind === "turn" ? diffSelection.revealRequestId : 0;
280 const selectedTurn =280 const selectedTurn =
281 selectedTurnId === null281 selectedRunId === null
282 ? undefined282 ? undefined
283 : (orderedTurnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ??283 : (orderedTurnDiffSummaries.find((summary) => summary.runId === selectedRunId) ??
284 orderedTurnDiffSummaries[0]);284 orderedTurnDiffSummaries[0]);
285 const selectedCheckpointTurnCount =285 const selectedCheckpointTurnCount =
286 selectedTurn &&286 selectedTurn &&
287 (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]);287 (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByRunId[selectedTurn.runId]);
288 const latestTurn = orderedTurnDiffSummaries[0];288 const latestTurn = orderedTurnDiffSummaries[0];
289 const selectedScopeLabel =289 const selectedScopeLabel =
290 selectedTurnId === null290 selectedRunId === null
291 ? selectedGitScope === "unstaged"291 ? selectedGitScope === "unstaged"
292 ? "Working tree"292 ? "Working tree"
293 : "Branch changes"293 : "Branch changes"
294 : selectedTurn?.turnId === latestTurn?.turnId294 : selectedTurn?.runId === latestTurn?.runId
295 ? "Latest turn"295 ? "Latest turn"
296 : `Turn ${selectedCheckpointTurnCount ?? "?"}`;296 : `Turn ${selectedCheckpointTurnCount ?? "?"}`;
297 const reviewSectionId = selectedTurn ? `turn:${selectedTurn.turnId}` : selectedGitScope;297 const reviewSectionId = selectedTurn ? `turn:${selectedTurn.runId}` : selectedGitScope;
298 const collapseScopeKey = routeThreadRef298 const collapseScopeKey = routeThreadRef
299 ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}`299 ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}`
300 : null;300 : null;
@@ -324,12 +324,12 @@ export default function DiffPanel({ · 23 unmodified lines
324 fromTurnCount: selectedCheckpointRange?.fromTurnCount ?? null,324 fromTurnCount: selectedCheckpointRange?.fromTurnCount ?? null,
325 toTurnCount: selectedCheckpointRange?.toTurnCount ?? null,325 toTurnCount: selectedCheckpointRange?.toTurnCount ?? null,
326 ignoreWhitespace: diffIgnoreWhitespace,326 ignoreWhitespace: diffIgnoreWhitespace,
327 cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : null,327 cacheScope: selectedTurn ? `turn:${selectedTurn.runId}` : null,
328 },328 },
329 { enabled: isGitRepo && selectedTurn !== undefined },329 { enabled: isGitRepo && selectedTurn !== undefined },
330 );330 );
331 const primaryBranchDiffPreview = useEnvironmentQuery(331 const primaryBranchDiffPreview = useEnvironmentQuery(
332 selectedTurnId === null && activeThread && activeCwd332 selectedRunId === null && activeThread && activeCwd
333 ? reviewEnvironment.diffPreview({333 ? reviewEnvironment.diffPreview({
334 environmentId: activeThread.environmentId,334 environmentId: activeThread.environmentId,
335 input: {335 input: {
@@ -341,7 +341,7 @@ export default function DiffPanel({ · 5 unmodified lines
341 : null,341 : null,
342 );342 );
343 const shouldRetryBranchDiffAtEnvironmentCwd =343 const shouldRetryBranchDiffAtEnvironmentCwd =
344 selectedTurnId === null &&344 selectedRunId === null &&
345 primaryBranchDiffPreview.error?.includes("configured workspace root") === true &&345 primaryBranchDiffPreview.error?.includes("configured workspace root") === true &&
346 serverConfig?.cwd !== undefined &&346 serverConfig?.cwd !== undefined &&
347 serverConfig.cwd !== activeCwd;347 serverConfig.cwd !== activeCwd;
@@ -364,7 +364,7 @@ export default function DiffPanel({ · 16 unmodified lines
364 (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"),364 (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"),
365 );365 );
366 const localBranchRefs = useEnvironmentQuery(366 const localBranchRefs = useEnvironmentQuery(
367 selectedTurnId === null &&367 selectedRunId === null &&
368 selectedGitScope === "branch" &&368 selectedGitScope === "branch" &&
369 activeThread &&369 activeThread &&
370 branchDiffPreview.data?.cwd370 branchDiffPreview.data?.cwd
@@ -381,7 +381,7 @@ export default function DiffPanel({ · 10 unmodified lines
381 : null,381 : null,
382 );382 );
383 const remoteBranchRefs = useEnvironmentQuery(383 const remoteBranchRefs = useEnvironmentQuery(
384 selectedTurnId === null &&384 selectedRunId === null &&
385 selectedGitScope === "branch" &&385 selectedGitScope === "branch" &&
386 activeThread &&386 activeThread &&
387 branchDiffPreview.data?.cwd387 branchDiffPreview.data?.cwd
@@ -424,9 +424,9 @@ export default function DiffPanel({ · 36 unmodified lines
424 const renderablePatch = useMemo(424 const renderablePatch = useMemo(
425 () =>425 () =>
426 getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`, {426 getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`, {
427 compactPartialHunkOffsets: selectedTurnId === null,427 compactPartialHunkOffsets: selectedRunId === null,
428 }),428 }),
429 [resolvedTheme, selectedPatch, selectedTurnId],429 [resolvedTheme, selectedPatch, selectedRunId],
430 );430 );
431 const renderableFiles = useMemo(() => {431 const renderableFiles = useMemo(() => {
432 if (!renderablePatch || renderablePatch.kind !== "files") {432 if (!renderablePatch || renderablePatch.kind !== "files") {
@@ -505,21 +505,9 @@ export default function DiffPanel({ · 72 unmodified lines
505 [collapseScopeKey],505 [collapseScopeKey],
506 );506 );
507 507
508 const toggleDiffFileCollapse = useCallback(() => {508 const selectTurn = (runId: RunId) => {
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) => {
521 if (!routeThreadRef) return;509 if (!routeThreadRef) return;
522 useDiffPanelStore.getState().selectTurn(routeThreadRef, turnId);510 useDiffPanelStore.getState().selectTurn(routeThreadRef, runId);
523 };511 };
524 const selectGitScope = (scope: "branch" | "unstaged") => {512 const selectGitScope = (scope: "branch" | "unstaged") => {
525 if (!routeThreadRef) return;513 if (!routeThreadRef) return;
@@ -551,6 +539,15 @@ export default function DiffPanel({ · 25 unmodified lines
551 onClick={() => selectGitScope("unstaged")}539 onClick={() => selectGitScope("unstaged")}
552 >540 >
553 <span>Working tree</span>541 <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 )}
554 </DropdownMenuItem>551 </DropdownMenuItem>
555 <DropdownMenuItem552 <DropdownMenuItem
556 className={553 className={
@@ -569,10 +566,13 @@ export default function DiffPanel({ · 12 unmodified lines
569 : undefined566 : undefined
570 }567 }
571 onClick={() => {568 onClick={() => {
572 if (latestTurn) selectTurn(latestTurn.turnId);569 if (latestTurn) selectTurn(latestTurn.runId);
573 }}570 }}
574 >571 >
575 <span>Latest turn</span>572 <span>Latest turn</span>
573 {selectedRunId !== null && selectedTurn?.runId === latestTurn?.runId && (
574 <CheckIcon className="ml-auto" />
575 )}
576 </DropdownMenuItem>576 </DropdownMenuItem>
577 <DropdownMenuSub>577 <DropdownMenuSub>
578 <DropdownMenuSubTrigger>Turn</DropdownMenuSubTrigger>578 <DropdownMenuSubTrigger>Turn</DropdownMenuSubTrigger>
@@ -580,20 +580,15 @@ export default function DiffPanel({ · 1 unmodified line
580 {orderedTurnDiffSummaries.map((summary) => {580 {orderedTurnDiffSummaries.map((summary) => {
581 const turnCount =581 const turnCount =
582 summary.checkpointTurnCount ??582 summary.checkpointTurnCount ??
583 inferredCheckpointTurnCountByTurnId[summary.turnId] ??583 inferredCheckpointTurnCountByRunId[summary.runId] ??
584 "?";584 "?";
585 return (585 return (
586 <DropdownMenuItem586 <DropdownMenuItem key={summary.runId} onClick={() => selectTurn(summary.runId)}>
587 key={summary.turnId}
588 className={
589 summary.turnId === selectedTurn?.turnId ? "bg-foreground/[0.08]" : undefined
590 }
591 onClick={() => selectTurn(summary.turnId)}
592 >
593 <span>Turn {turnCount}</span>587 <span>Turn {turnCount}</span>
594 <span className="ml-auto text-xs tabular-nums text-muted-foreground">588 <span className="ml-auto text-xs tabular-nums text-muted-foreground">
595 {formatShortTimestamp(summary.completedAt, settings.timestampFormat)}589 {formatShortTimestamp(summary.completedAt, settings.timestampFormat)}
596 </span>590 </span>
591 {summary.runId === selectedTurn?.runId && <CheckIcon className="ml-1" />}
597 </DropdownMenuItem>592 </DropdownMenuItem>
598 );593 );
599 })}594 })}
@@ -601,7 +596,7 @@ export default function DiffPanel({ · 1 unmodified line
601 </DropdownMenuSub>596 </DropdownMenuSub>
602 </DropdownMenuContent>597 </DropdownMenuContent>
603 </DropdownMenu>598 </DropdownMenu>
604 {selectedTurnId === null && selectedGitScope === "branch" && selectedGitSource?.baseRef && (599 {selectedRunId === null && selectedGitScope === "branch" && selectedGitSource?.baseRef && (
605 <div600 <div
606 className="flex min-w-0 max-w-full items-center gap-2 overflow-hidden text-xs text-muted-foreground"601 className="flex min-w-0 max-w-full items-center gap-2 overflow-hidden text-xs text-muted-foreground"
607 title={`${selectedGitSource.headRef ?? "HEAD"} → ${selectedGitSource.baseRef}`}602 title={`${selectedGitSource.headRef ?? "HEAD"} → ${selectedGitSource.baseRef}`}
@@ -823,7 +818,7 @@ export default function DiffPanel({ · 215 unmodified lines
823 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">818 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
824 Turn diffs are unavailable because this project is not a git repository.819 Turn diffs are unavailable because this project is not a git repository.
825 </div>820 </div>
826 ) : selectedTurnId !== null && orderedTurnDiffSummaries.length === 0 ? (821 ) : selectedRunId !== null && orderedTurnDiffSummaries.length === 0 ? (
827 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">822 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
828 No completed turns yet.823 No completed turns yet.
829 </div>824 </div>
apps/web/src/components/Sidebar.logic.test.ts+34−44
@@ -31,20 +31,14 @@ import { · 30 unmodified lines
31 sortScopedProjectsForSidebar,31 sortScopedProjectsForSidebar,
32 THREAD_JUMP_HINT_SHOW_DELAY_MS,32 THREAD_JUMP_HINT_SHOW_DELAY_MS,
33} from "./Sidebar.logic";33} from "./Sidebar.logic";
34import {34import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
35 EnvironmentId,
36 OrchestrationLatestTurn,
37 ProjectId,
38 ProviderInstanceId,
39 ThreadId,
40} from "@t3tools/contracts";
41
42import {35import {
43 DEFAULT_INTERACTION_MODE,36 DEFAULT_INTERACTION_MODE,
44 DEFAULT_RUNTIME_MODE,37 DEFAULT_RUNTIME_MODE,
45 type Project,38 type Project,
46 type Thread,39 type Thread,
47} from "../types";40} from "../types";
41import { makeThreadFixture } from "../test-fixtures";
48 42
49const localEnvironmentId = EnvironmentId.make("environment-local");43const localEnvironmentId = EnvironmentId.make("environment-local");
50 44
@@ -239,13 +233,13 @@ describe("resolveSidebarStageBadgeLabel", () => { · 188 unmodified lines
239 });233 });
240});234});
241 235
242function makeLatestTurn(overrides?: {236function makeLatestRun(overrides?: {
243 completedAt?: string | null;237 completedAt?: string | null;
244 startedAt?: string | null;238 startedAt?: string | null;
245}): OrchestrationLatestTurn {239}): NonNullable<Thread["latestRun"]> {
246 return {240 return {
247 turnId: "turn-1" as never,241 runId: "turn-1" as never,
248 state: "completed",242 status: "completed",
249 assistantMessageId: null,243 assistantMessageId: null,
250 requestedAt: "2026-03-09T10:00:00.000Z",244 requestedAt: "2026-03-09T10:00:00.000Z",
251 startedAt:245 startedAt:
@@ -263,9 +257,9 @@ describe("hasUnseenCompletion", () => { · 11 unmodified lines
263 hasPendingApprovals: false,257 hasPendingApprovals: false,
264 hasPendingUserInput: false,258 hasPendingUserInput: false,
265 interactionMode: "default",259 interactionMode: "default",
266 latestTurn: makeLatestTurn(),260 latestRun: makeLatestRun(),
267 lastVisitedAt: "2026-03-09T10:04:00.000Z",261 lastVisitedAt: "2026-03-09T10:04:00.000Z",
268 session: null,262 runtime: null,
269 }),263 }),
270 ).toBe(true);264 ).toBe(true);
271 });265 });
@@ -277,9 +271,9 @@ describe("hasUnseenCompletion", () => { · 5 unmodified lines
277 hasPendingApprovals: false,271 hasPendingApprovals: false,
278 hasPendingUserInput: false,272 hasPendingUserInput: false,
279 interactionMode: "default",273 interactionMode: "default",
280 latestTurn: makeLatestTurn(),274 latestRun: makeLatestRun(),
281 lastVisitedAt: undefined,275 lastVisitedAt: undefined,
282 session: null,276 runtime: null,
283 }),277 }),
284 ).toBe(false);278 ).toBe(false);
285 });279 });
@@ -879,15 +873,13 @@ describe("resolveThreadStatusPill", () => { · 593 unmodified lines
879 hasPendingApprovals: false,873 hasPendingApprovals: false,
880 hasPendingUserInput: false,874 hasPendingUserInput: false,
881 interactionMode: "plan" as const,875 interactionMode: "plan" as const,
882 latestTurn: null,876 latestRun: null,
883 lastVisitedAt: undefined,877 lastVisitedAt: undefined,
884 session: {878 runtime: {
885 threadId: ThreadId.make("thread-1"),
886 status: "running" as const,879 status: "running" as const,
887 providerName: "Codex",880 providerName: "Codex",
888 providerInstanceId: ProviderInstanceId.make("codex"),881 providerInstanceId: ProviderInstanceId.make("codex"),
889 runtimeMode: DEFAULT_RUNTIME_MODE,882 activeRunId: "turn-1" as never,
890 activeTurnId: "turn-1" as never,
891 lastError: null,883 lastError: null,
892 updatedAt: "2026-03-09T10:00:00.000Z",884 updatedAt: "2026-03-09T10:00:00.000Z",
893 },885 },
@@ -930,11 +922,11 @@ describe("resolveThreadStatusPill", () => { · 36 unmodified lines
930 thread: {922 thread: {
931 ...baseThread,923 ...baseThread,
932 hasActionableProposedPlan: true,924 hasActionableProposedPlan: true,
933 latestTurn: makeLatestTurn(),925 latestRun: makeLatestRun(),
934 session: {926 runtime: {
935 ...baseThread.session,927 ...baseThread.runtime,
936 status: "ready",928 status: "completed",
937 activeTurnId: null,929 activeRunId: null,
938 },930 },
939 },931 },
940 }),932 }),
@@ -946,11 +938,11 @@ describe("resolveThreadStatusPill", () => { · 5 unmodified lines
946 resolveThreadStatusPill({938 resolveThreadStatusPill({
947 thread: {939 thread: {
948 ...baseThread,940 ...baseThread,
949 latestTurn: makeLatestTurn(),941 latestRun: makeLatestRun(),
950 session: {942 runtime: {
951 ...baseThread.session,943 ...baseThread.runtime,
952 status: "ready",944 status: "completed",
953 activeTurnId: null,945 activeRunId: null,
954 },946 },
955 },947 },
956 }),948 }),
@@ -963,12 +955,12 @@ describe("resolveThreadStatusPill", () => { · 6 unmodified lines
963 thread: {955 thread: {
964 ...baseThread,956 ...baseThread,
965 interactionMode: "default",957 interactionMode: "default",
966 latestTurn: makeLatestTurn(),958 latestRun: makeLatestRun(),
967 lastVisitedAt: "2026-03-09T10:04:00.000Z",959 lastVisitedAt: "2026-03-09T10:04:00.000Z",
968 session: {960 runtime: {
969 ...baseThread.session,961 ...baseThread.runtime,
970 status: "ready",962 status: "completed",
971 activeTurnId: null,963 activeRunId: null,
972 },964 },
973 },965 },
974 }),966 }),
@@ -1120,7 +1112,7 @@ function makeProject(overrides: Partial<Project> = {}): Project { · 145 unmodified lines
1120}1112}
1121 1113
1122function makeThread(overrides: Partial<Thread> = {}): Thread {1114function makeThread(overrides: Partial<Thread> = {}): Thread {
1123 return {1115 return makeThreadFixture({
1124 id: ThreadId.make("thread-1"),1116 id: ThreadId.make("thread-1"),
1125 environmentId: localEnvironmentId,1117 environmentId: localEnvironmentId,
1126 projectId: ProjectId.make("project-1"),1118 projectId: ProjectId.make("project-1"),
@@ -1132,7 +1124,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 5 unmodified lines
1132 },1124 },
1133 runtimeMode: DEFAULT_RUNTIME_MODE,1125 runtimeMode: DEFAULT_RUNTIME_MODE,
1134 interactionMode: DEFAULT_INTERACTION_MODE,1126 interactionMode: DEFAULT_INTERACTION_MODE,
1135 session: null,1127 runtime: null,
1136 messages: [],1128 messages: [],
1137 proposedPlans: [],1129 proposedPlans: [],
1138 createdAt: "2026-03-09T10:00:00.000Z",1130 createdAt: "2026-03-09T10:00:00.000Z",
@@ -1141,13 +1133,11 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 2 unmodified lines
1141 settledAt: null,1133 settledAt: null,
1142 deletedAt: null,1134 deletedAt: null,
1143 updatedAt: "2026-03-09T10:00:00.000Z",1135 updatedAt: "2026-03-09T10:00:00.000Z",
1144 latestTurn: null,1136 latestRun: null,
1145 branch: null,1137 branch: null,
1146 worktreePath: null,1138 worktreePath: null,
1147 checkpoints: [],
1148 activities: [],
1149 ...overrides,1139 ...overrides,
1150 };1140 });
1151}1141}
1152 1142
1153describe("getFallbackThreadIdAfterDelete", () => {1143describe("getFallbackThreadIdAfterDelete", () => {
@@ -1231,7 +1221,7 @@ describe("sortProjectsForSidebar", () => { · 77 unmodified lines
1231 id: "message-1" as never,1221 id: "message-1" as never,
1232 role: "user",1222 role: "user",
1233 text: "older project user message",1223 text: "older project user message",
1234 turnId: null,1224 runId: null,
1235 createdAt: "2026-03-09T10:01:00.000Z",1225 createdAt: "2026-03-09T10:01:00.000Z",
1236 updatedAt: "2026-03-09T10:01:00.000Z",1226 updatedAt: "2026-03-09T10:01:00.000Z",
1237 streaming: false,1227 streaming: false,
@@ -1247,7 +1237,7 @@ describe("sortProjectsForSidebar", () => { · 9 unmodified lines
1247 id: "message-2" as never,1237 id: "message-2" as never,
1248 role: "user",1238 role: "user",
1249 text: "newer project user message",1239 text: "newer project user message",
1250 turnId: null,1240 runId: null,
1251 createdAt: "2026-03-09T10:05:00.000Z",1241 createdAt: "2026-03-09T10:05:00.000Z",
1252 updatedAt: "2026-03-09T10:05:00.000Z",1242 updatedAt: "2026-03-09T10:05:00.000Z",
1253 streaming: false,1243 streaming: false,
apps/web/src/components/Sidebar.logic.ts+8−8
@@ -10,7 +10,7 @@ import { · 9 unmodified lines
10import type { SidebarThreadSummary, Thread } from "../types";10import type { SidebarThreadSummary, Thread } from "../types";
11import type { ThreadRouteTarget } from "../threadRoutes";11import type { ThreadRouteTarget } from "../threadRoutes";
12import { cn } from "../lib/utils";12import { cn } from "../lib/utils";
13import { isLatestTurnSettled } from "../session-logic";13import { isLatestRunSettled } from "../session-logic";
14import { resolveServerBackedAppStageLabel } from "../branding.logic";14import { resolveServerBackedAppStageLabel } from "../branding.logic";
15 15
16export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";16export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
@@ -141,8 +141,8 @@ type ThreadStatusInput = Pick< · 124 unmodified lines
141 | "hasPendingApprovals"141 | "hasPendingApprovals"
142 | "hasPendingUserInput"142 | "hasPendingUserInput"
143 | "interactionMode"143 | "interactionMode"
144 | "latestTurn"144 | "latestRun"
145 | "session"145 | "runtime"
146> & {146> & {
147 lastVisitedAt?: string | undefined;147 lastVisitedAt?: string | undefined;
148};148};
@@ -240,8 +240,8 @@ export function useThreadJumpHintVisibility(): { · 91 unmodified lines
240}240}
241 241
242export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {242export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {
243 if (!thread.latestTurn?.completedAt) return false;243 if (!thread.latestRun?.completedAt) return false;
244 const completedAt = Date.parse(thread.latestTurn.completedAt);244 const completedAt = Date.parse(thread.latestRun.completedAt);
245 if (Number.isNaN(completedAt)) return false;245 if (Number.isNaN(completedAt)) return false;
246 if (!thread.lastVisitedAt) return false;246 if (!thread.lastVisitedAt) return false;
247 247
@@ -594,7 +594,7 @@ export function resolveThreadStatusPill(input: { · 346 unmodified lines
594 };594 };
595 }595 }
596 596
597 if (thread.session?.status === "running") {597 if (thread.runtime?.status === "running" || thread.runtime?.status === "waiting") {
598 return {598 return {
599 label: "Working",599 label: "Working",
600 colorClass: "text-sky-600 dark:text-sky-300/80",600 colorClass: "text-sky-600 dark:text-sky-300/80",
@@ -603,7 +603,7 @@ export function resolveThreadStatusPill(input: { · 2 unmodified lines
603 };603 };
604 }604 }
605 605
606 if (thread.session?.status === "starting") {606 if (thread.runtime?.status === "starting" || thread.runtime?.status === "queued") {
607 return {607 return {
608 label: "Connecting",608 label: "Connecting",
609 colorClass: "text-sky-600 dark:text-sky-300/80",609 colorClass: "text-sky-600 dark:text-sky-300/80",
@@ -615,7 +615,7 @@ export function resolveThreadStatusPill(input: { · 5 unmodified lines
615 const hasPlanReadyPrompt =615 const hasPlanReadyPrompt =
616 !thread.hasPendingUserInput &&616 !thread.hasPendingUserInput &&
617 thread.interactionMode === "plan" &&617 thread.interactionMode === "plan" &&
618 isLatestTurnSettled(thread.latestTurn, thread.session) &&618 isLatestRunSettled(thread.latestRun, thread.runtime) &&
619 thread.hasActionableProposedPlan;619 thread.hasActionableProposedPlan;
620 if (hasPlanReadyPrompt) {620 if (hasPlanReadyPrompt) {
621 return {621 return {
apps/web/src/components/Sidebar.tsx+5−4
@@ -442,7 +442,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr · 441 unmodified lines
442 [discoveredPorts, navigateToThread, openPreview, threadRef],442 [discoveredPorts, navigateToThread, openPreview, threadRef],
443 );443 );
444 const isThreadRunning =444 const isThreadRunning =
445 thread.session?.status === "running" && thread.session.activeTurnId != null;445 thread.runtime?.status === "running" && thread.runtime.activeRunId != null;
446 const threadStatus = resolveThreadStatusPill({446 const threadStatus = resolveThreadStatusPill({
447 thread: {447 thread: {
448 ...thread,448 ...thread,
@@ -1777,8 +1777,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 1328 unmodified lines
1777 );1777 );
1778 1778
1779 if (clicked === "mark-unread") {1779 if (clicked === "mark-unread") {
1780 for (const { threadKey, thread } of selectedThreadEntries) {1780 for (const threadKey of threadKeys) {
1781 markThreadUnread(threadKey, thread.latestTurn?.completedAt);1781 const thread = sidebarThreadByKeyRef.current.get(threadKey);
1782 markThreadUnread(threadKey, thread?.latestRun?.completedAt);
1782 }1783 }
1783 clearSelection();1784 clearSelection();
1784 return;1785 return;
@@ -2155,7 +2156,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 370 unmodified lines
2155 }2156 }
2156 2157
2157 if (clicked === "mark-unread") {2158 if (clicked === "mark-unread") {
2158 markThreadUnread(threadKey, thread.latestTurn?.completedAt);2159 markThreadUnread(threadKey, thread.latestRun?.completedAt);
2159 return;2160 return;
2160 }2161 }
2161 if (clicked === "copy-path") {2162 if (clicked === "copy-path") {
apps/web/src/components/chat/ChangedFilesTree.test.tsx+3−3
@@ -1,4 +1,4 @@
1import { TurnId } from "@t3tools/contracts";1import { RunId } from "@t3tools/contracts";
2import { renderToStaticMarkup } from "react-dom/server";2import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it } from "vite-plus/test";3import { describe, expect, it } from "vite-plus/test";
4 4
@@ -142,7 +142,7 @@ describe("ChangedFilesTree", () => { · 137 unmodified lines
142 ({ files, visibleLabels, hiddenLabels }) => {142 ({ files, visibleLabels, hiddenLabels }) => {
143 const markup = renderToStaticMarkup(143 const markup = renderToStaticMarkup(
144 <ChangedFilesTree144 <ChangedFilesTree
145 turnId={TurnId.make("turn-1")}145 runId={RunId.make("turn-1")}
146 files={files}146 files={files}
147 allDirectoriesExpanded={false}147 allDirectoriesExpanded={false}
148 resolvedTheme="light"148 resolvedTheme="light"
@@ -218,7 +218,7 @@ describe("ChangedFilesTree", () => { · 69 unmodified lines
218 ({ files, visibleLabels }) => {218 ({ files, visibleLabels }) => {
219 const markup = renderToStaticMarkup(219 const markup = renderToStaticMarkup(
220 <ChangedFilesTree220 <ChangedFilesTree
221 turnId={TurnId.make("turn-1")}221 runId={RunId.make("turn-1")}
222 files={files}222 files={files}
223 allDirectoriesExpanded223 allDirectoriesExpanded
224 resolvedTheme="light"224 resolvedTheme="light"
apps/web/src/components/chat/ChangedFilesTree.tsx+27−55
@@ -1,4 +1,4 @@
1import { type TurnId } from "@t3tools/contracts";1import { type RunId } from "@t3tools/contracts";
2import { memo, useCallback, useMemo, useState } from "react";2import { memo, useCallback, useMemo, useState } from "react";
3import { type TurnDiffFileChange } from "../../types";3import { type TurnDiffFileChange } from "../../types";
4import {4import {
@@ -28,7 +28,7 @@ import { · 23 unmodified lines
28const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};28const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};
29 29
30export const ChangedFilesCard = memo(function ChangedFilesCard(props: {30export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
31 turnId: TurnId;31 runId: RunId;
32 files: ReadonlyArray<TurnDiffFileChange>;32 files: ReadonlyArray<TurnDiffFileChange>;
33 expanded: boolean;33 expanded: boolean;
34 showCompactPreview: boolean;34 showCompactPreview: boolean;
@@ -36,10 +36,10 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { · 1 unmodified line
36 resolvedTheme: "light" | "dark";36 resolvedTheme: "light" | "dark";
37 onExpandedChange: (expanded: boolean) => void;37 onExpandedChange: (expanded: boolean) => void;
38 onToggleAllDirectories: () => void;38 onToggleAllDirectories: () => void;
39 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;39 onOpenTurnDiff: (runId: RunId, filePath?: string) => void;
40}) {40}) {
41 const {41 const {
42 turnId,42 runId,
43 files,43 files,
44 expanded,44 expanded,
45 showCompactPreview,45 showCompactPreview,
@@ -100,57 +100,29 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { · 54 unmodified lines
100 </span>100 </span>
101 </button>101 </button>
102 <div className="flex items-center gap-1.5">102 <div className="flex items-center gap-1.5">
103 {expanded ? (103 <Button
104 <Tooltip>104 type="button"
105 <TooltipTrigger105 size="xs"
106 render={106 variant="outline"
107 <Button107 data-scroll-anchor-ignore
108 type="button"108 onClick={onToggleAllDirectories}
109 size="icon-xs"109 >
110 variant="outline"110 {allDirectoriesExpanded ? "Collapse all" : "Expand all"}
111 className="!size-[22px]"111 </Button>
112 aria-label={112 <Button
113 allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"113 type="button"
114 }114 size="xs"
115 data-scroll-anchor-ignore115 variant="outline"
116 onClick={onToggleAllDirectories}116 onClick={() => onOpenTurnDiff(runId, files[0]?.path)}
117 />117 >
118 }118 View diff
119 >119 </Button>
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>
148 </div>120 </div>
149 </div>121 </div>
150 {expanded ? (122 {expanded ? (
151 <ChangedFilesTree123 <ChangedFilesTree
152 key={`changed-files-tree:${turnId}`}124 key={`changed-files-tree:${runId}`}
153 turnId={turnId}125 runId={runId}
154 files={files}126 files={files}
155 allDirectoriesExpanded={allDirectoriesExpanded}127 allDirectoriesExpanded={allDirectoriesExpanded}
156 resolvedTheme={resolvedTheme}128 resolvedTheme={resolvedTheme}
@@ -202,13 +174,13 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { · 45 unmodified lines
202});174});
203 175
204export const ChangedFilesTree = memo(function ChangedFilesTree(props: {176export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
205 turnId: TurnId;177 runId: RunId;
206 files: ReadonlyArray<TurnDiffFileChange>;178 files: ReadonlyArray<TurnDiffFileChange>;
207 allDirectoriesExpanded: boolean;179 allDirectoriesExpanded: boolean;
208 resolvedTheme: "light" | "dark";180 resolvedTheme: "light" | "dark";
209 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;181 onOpenTurnDiff: (runId: RunId, filePath?: string) => void;
210}) {182}) {
211 const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, turnId } = props;183 const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, runId } = props;
212 const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]);184 const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]);
213 const directoryPathsKey = useMemo(185 const directoryPathsKey = useMemo(
214 () => collectDirectoryPaths(treeNodes).join("\u0000"),186 () => collectDirectoryPaths(treeNodes).join("\u0000"),
@@ -293,7 +265,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { · 78 unmodified lines
293 type="button"265 type="button"
294 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"266 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"
295 style={{ paddingLeft: `${leftPadding}px` }}267 style={{ paddingLeft: `${leftPadding}px` }}
296 onClick={() => onOpenTurnDiff(turnId, node.path)}268 onClick={() => onOpenTurnDiff(runId, node.path)}
297 >269 >
298 {hasDirectoryNodes || depth > 0 ? (270 {hasDirectoryNodes || depth > 0 ? (
299 <span aria-hidden="true" className="size-3.5 shrink-0" />271 <span aria-hidden="true" className="size-3.5 shrink-0" />
apps/web/src/components/chat/ChatComposer.tsx+40−16
@@ -1,5 +1,4 @@
1import type {1import type {
2 ApprovalRequestId,
3 EnvironmentId,2 EnvironmentId,
4 ModelSelection,3 ModelSelection,
5 PreviewAnnotationPayload,4 PreviewAnnotationPayload,
@@ -10,7 +9,8 @@ import type { · 4 unmodified lines
10 ScopedThreadRef,9 ScopedThreadRef,
11 ServerProvider,10 ServerProvider,
12 ThreadId,11 ThreadId,
13 TurnId,12 RunId,
13 RuntimeRequestId,
14} from "@t3tools/contracts";14} from "@t3tools/contracts";
15import {15import {
16 ProviderDriverKind,16 ProviderDriverKind,
@@ -548,13 +548,13 @@ export interface ChatComposerProps { · 531 unmodified lines
548 activePendingIsResponding: boolean;548 activePendingIsResponding: boolean;
549 activePendingDraftAnswers: Record<string, PendingUserInputDraftAnswer>;549 activePendingDraftAnswers: Record<string, PendingUserInputDraftAnswer>;
550 activePendingQuestionIndex: number;550 activePendingQuestionIndex: number;
551 respondingRequestIds: ApprovalRequestId[];551 respondingRequestIds: RuntimeRequestId[];
552 552
553 // Plan553 // Plan
554 showPlanFollowUpPrompt: boolean;554 showPlanFollowUpPrompt: boolean;
555 activeProposedPlan: Thread["proposedPlans"][number] | null;555 activeProposedPlan: Thread["proposedPlans"][number] | null;
556 activePlan: { turnId?: TurnId } | null;556 activePlan: { runId?: RunId | null } | null;
557 sidebarProposedPlan: { turnId?: TurnId } | null;557 sidebarProposedPlan: { runId?: RunId | null } | null;
558 planSidebarLabel: string;558 planSidebarLabel: string;
559 planSidebarOpen: boolean;559 planSidebarOpen: boolean;
560 560
@@ -569,7 +569,7 @@ export interface ChatComposerProps { · 8 unmodified lines
569 activeThreadModelSelection: ModelSelection | null | undefined;569 activeThreadModelSelection: ModelSelection | null | undefined;
570 570
571 // Context window571 // Context window
572 activeThreadActivities: Thread["activities"] | undefined;572 activeThreadWorkEntries: Thread["workEntries"] | undefined;
573 573
574 // Misc574 // Misc
575 resolvedTheme: "light" | "dark";575 resolvedTheme: "light" | "dark";
@@ -585,12 +585,16 @@ export interface ChatComposerProps { · 9 unmodified lines
585 composerElementContextsRef: React.RefObject<ElementContextDraft[]>;585 composerElementContextsRef: React.RefObject<ElementContextDraft[]>;
586 composerRef: React.RefObject<ChatComposerHandle | null>;586 composerRef: React.RefObject<ChatComposerHandle | null>;
587 587
588 // Scroll
589 shouldAutoScrollRef: React.RefObject<boolean>;
590 scheduleStickToBottom: () => void;
591
588 // Callbacks592 // Callbacks
589 onSend: (e?: { preventDefault: () => void }) => void;593 onSend: (e?: { preventDefault: () => void }) => void;
590 onInterrupt: () => void;594 onInterrupt: () => void;
591 onImplementPlanInNewThread: () => void;595 onImplementPlanInNewThread: () => void;
592 onRespondToApproval: (596 onRespondToApproval: (
593 requestId: ApprovalRequestId,597 requestId: RuntimeRequestId,
594 decision: ProviderApprovalDecision,598 decision: ProviderApprovalDecision,
595 ) => Promise<unknown>;599 ) => Promise<unknown>;
596 onSelectActivePendingUserInputOption: (questionId: string, optionLabel: string) => void;600 onSelectActivePendingUserInputOption: (questionId: string, optionLabel: string) => void;
@@ -662,7 +666,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 65 unmodified lines
662 providerStatuses,666 providerStatuses,
663 activeProjectDefaultModelSelection,667 activeProjectDefaultModelSelection,
664 activeThreadModelSelection,668 activeThreadModelSelection,
665 activeThreadActivities,669 activeThreadWorkEntries,
666 resolvedTheme,670 resolvedTheme,
667 settings,671 settings,
668 keybindings,672 keybindings,
@@ -673,6 +677,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 4 unmodified lines
673 composerImagesRef,677 composerImagesRef,
674 composerTerminalContextsRef,678 composerTerminalContextsRef,
675 composerElementContextsRef,679 composerElementContextsRef,
680 shouldAutoScrollRef,
681 scheduleStickToBottom,
676 onSend,682 onSend,
677 onInterrupt,683 onInterrupt,
678 onImplementPlanInNewThread,684 onImplementPlanInNewThread,
@@ -754,7 +760,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 75 unmodified lines
754 );760 );
755 const selectedProviderByThreadId = composerDraft.activeProvider ?? null;761 const selectedProviderByThreadId = composerDraft.activeProvider ?? null;
756 const threadProvider =762 const threadProvider =
757 activeThread?.session?.providerInstanceId ??763 activeThread?.runtime?.providerInstanceId ??
758 activeThreadModelSelection?.instanceId ??764 activeThreadModelSelection?.instanceId ??
759 activeProjectDefaultModelSelection?.instanceId ??765 activeProjectDefaultModelSelection?.instanceId ??
760 null;766 null;
@@ -772,7 +778,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 11 unmodified lines
772 const lockedContinuationGroupKey = useMemo((): string | null => {778 const lockedContinuationGroupKey = useMemo((): string | null => {
773 if (!lockedProvider || !activeThread) return null;779 if (!lockedProvider || !activeThread) return null;
774 const lockedInstanceId =780 const lockedInstanceId =
775 activeThread.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId;781 activeThread.runtime?.providerInstanceId ?? activeThreadModelSelection?.instanceId;
776 if (!lockedInstanceId) return null;782 if (!lockedInstanceId) return null;
777 return (783 return (
778 providerInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId)784 providerInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId)
@@ -798,7 +804,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 19 unmodified lines
798 const selectedInstanceId = useMemo<ProviderInstanceId>(() => {804 const selectedInstanceId = useMemo<ProviderInstanceId>(() => {
799 const candidates: Array<string | null | undefined> = [805 const candidates: Array<string | null | undefined> = [
800 composerDraft.activeProvider,806 composerDraft.activeProvider,
801 activeThread?.session?.providerInstanceId,807 activeThread?.runtime?.providerInstanceId,
802 activeThreadModelSelection?.instanceId,808 activeThreadModelSelection?.instanceId,
803 activeProjectDefaultModelSelection?.instanceId,809 activeProjectDefaultModelSelection?.instanceId,
804 ];810 ];
@@ -835,7 +841,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 30 unmodified lines
835 );841 );
836 }, [842 }, [
837 activeProjectDefaultModelSelection?.instanceId,843 activeProjectDefaultModelSelection?.instanceId,
838 activeThread?.session?.providerInstanceId,844 activeThread?.runtime?.providerInstanceId,
839 activeThreadModelSelection?.instanceId,845 activeThreadModelSelection?.instanceId,
840 composerDraft.activeProvider,846 composerDraft.activeProvider,
841 lockedContinuationGroupKey,847 lockedContinuationGroupKey,
@@ -939,8 +945,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 97 unmodified lines
939 // Context window945 // Context window
940 // ------------------------------------------------------------------946 // ------------------------------------------------------------------
941 const activeContextWindow = useMemo(947 const activeContextWindow = useMemo(
942 () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []),948 () => deriveLatestContextWindowSnapshot(activeThreadWorkEntries ?? []),
943 [activeThreadActivities],949 [activeThreadWorkEntries],
944 );950 );
945 const activeThreadProviderDisplayName = useMemo(() => {951 const activeThreadProviderDisplayName = useMemo(() => {
946 if (!activeThreadModelSelection) return null;952 if (!activeThreadModelSelection) return null;
@@ -987,6 +993,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 40 unmodified lines
987 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);993 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
988 const composerFormRef = useRef<HTMLFormElement>(null);994 const composerFormRef = useRef<HTMLFormElement>(null);
989 const composerSurfaceRef = useRef<HTMLDivElement>(null);995 const composerSurfaceRef = useRef<HTMLDivElement>(null);
996 const composerFormHeightRef = useRef(0);
990 const composerSelectLockRef = useRef(false);997 const composerSelectLockRef = useRef(false);
991 const composerMenuOpenRef = useRef(false);998 const composerMenuOpenRef = useRef(false);
992 const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);999 const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);
@@ -1441,12 +1448,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 448 unmodified lines
1441 };1448 };
1442 };1449 };
1443 1450
1451 composerFormHeightRef.current = composerForm.getBoundingClientRect().height;
1444 const initialCompactness = measureFooterCompactness();1452 const initialCompactness = measureFooterCompactness();
1445 setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact);1453 setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact);
1446 setIsComposerFooterCompact(initialCompactness.footerCompact);1454 setIsComposerFooterCompact(initialCompactness.footerCompact);
1447 if (typeof ResizeObserver === "undefined") return;1455 if (typeof ResizeObserver === "undefined") return;
1448 1456
1449 const observer = new ResizeObserver(() => {1457 const observer = new ResizeObserver((entries) => {
1458 const [entry] = entries;
1459 if (!entry) return;
1450 const nextCompactness = measureFooterCompactness();1460 const nextCompactness = measureFooterCompactness();
1451 setIsComposerPrimaryActionsCompact((previous) =>1461 setIsComposerPrimaryActionsCompact((previous) =>
1452 previous === nextCompactness.primaryActionsCompact1462 previous === nextCompactness.primaryActionsCompact
@@ -1456,13 +1466,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 3 unmodified lines
1456 setIsComposerFooterCompact((previous) =>1466 setIsComposerFooterCompact((previous) =>
1457 previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact,1467 previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact,
1458 );1468 );
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();
1459 });1475 });
1460 1476
1461 observer.observe(composerForm);1477 observer.observe(composerForm);
1462 return () => {1478 return () => {
1463 observer.disconnect();1479 observer.disconnect();
1464 };1480 };
1465 }, [activeThreadId, composerFooterActionLayoutKey, composerFooterHasWideActions]);1481 }, [
1482 activeThreadId,
1483 composerFooterActionLayoutKey,
1484 composerFooterHasWideActions,
1485 scheduleStickToBottom,
1486 shouldAutoScrollRef,
1487 ]);
1466 1488
1467 // ------------------------------------------------------------------1489 // ------------------------------------------------------------------
1468 // Image persist effect1490 // Image persist effect
@@ -2752,6 +2774,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 1283 unmodified lines
2752 <ComposerPendingApprovalActions2774 <ComposerPendingApprovalActions
2753 requestId={activePendingApproval.requestId}2775 requestId={activePendingApproval.requestId}
2754 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}2776 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
2777 canRespond={activePendingApproval.responseCapability === "live"}
2755 onRespondToApproval={onRespondToApproval}2778 onRespondToApproval={onRespondToApproval}
2756 />2779 />
2757 </div>2780 </div>
@@ -3108,6 +3131,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 350 unmodified lines
3108 <ComposerPendingApprovalActions3131 <ComposerPendingApprovalActions
3109 requestId={activePendingApproval.requestId}3132 requestId={activePendingApproval.requestId}
3110 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}3133 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
3134 canRespond={activePendingApproval.responseCapability === "live"}
3111 onRespondToApproval={onRespondToApproval}3135 onRespondToApproval={onRespondToApproval}
3112 />3136 />
3113 </div>3137 </div>
apps/web/src/components/chat/ComposerPendingApprovalActions.tsx+9−7
@@ -1,12 +1,13 @@
1import { type ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts";1import { type RuntimeRequestId, type ProviderApprovalDecision } from "@t3tools/contracts";
2import { memo } from "react";2import { memo } from "react";
3import { Button } from "../ui/button";3import { Button } from "../ui/button";
4 4
5interface ComposerPendingApprovalActionsProps {5interface ComposerPendingApprovalActionsProps {
6 requestId: ApprovalRequestId;6 requestId: RuntimeRequestId;
7 isResponding: boolean;7 isResponding: boolean;
8 canRespond: boolean;
8 onRespondToApproval: (9 onRespondToApproval: (
9 requestId: ApprovalRequestId,10 requestId: RuntimeRequestId,
10 decision: ProviderApprovalDecision,11 decision: ProviderApprovalDecision,
11 ) => Promise<unknown>;12 ) => Promise<unknown>;
12}13}
@@ -14,6 +15,7 @@ interface ComposerPendingApprovalActionsProps { · 1 unmodified line
14export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({15export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({
15 requestId,16 requestId,
16 isResponding,17 isResponding,
18 canRespond,
17 onRespondToApproval,19 onRespondToApproval,
18}: ComposerPendingApprovalActionsProps) {20}: ComposerPendingApprovalActionsProps) {
19 return (21 return (
@@ -21,7 +23,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro · 1 unmodified line
21 <Button23 <Button
22 size="sm"24 size="sm"
23 variant="ghost"25 variant="ghost"
24 disabled={isResponding}26 disabled={isResponding || !canRespond}
25 onClick={() => void onRespondToApproval(requestId, "cancel")}27 onClick={() => void onRespondToApproval(requestId, "cancel")}
26 >28 >
27 Cancel turn29 Cancel turn
@@ -29,7 +31,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro · 1 unmodified line
29 <Button31 <Button
30 size="sm"32 size="sm"
31 variant="destructive-outline"33 variant="destructive-outline"
32 disabled={isResponding}34 disabled={isResponding || !canRespond}
33 onClick={() => void onRespondToApproval(requestId, "decline")}35 onClick={() => void onRespondToApproval(requestId, "decline")}
34 >36 >
35 Decline37 Decline
@@ -37,7 +39,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro · 1 unmodified line
37 <Button39 <Button
38 size="sm"40 size="sm"
39 variant="outline"41 variant="outline"
40 disabled={isResponding}42 disabled={isResponding || !canRespond}
41 onClick={() => void onRespondToApproval(requestId, "acceptForSession")}43 onClick={() => void onRespondToApproval(requestId, "acceptForSession")}
42 >44 >
43 Always allow this session45 Always allow this session
@@ -45,7 +47,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro · 1 unmodified line
45 <Button47 <Button
46 size="sm"48 size="sm"
47 variant="default"49 variant="default"
48 disabled={isResponding}50 disabled={isResponding || !canRespond}
49 onClick={() => void onRespondToApproval(requestId, "accept")}51 onClick={() => void onRespondToApproval(requestId, "accept")}
50 >52 >
51 Approve once53 Approve once
apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx+5−11
@@ -32,17 +32,11 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova · 31 unmodified lines
32 <span className="text-xs text-muted-foreground">1/{pendingCount}</span>32 <span className="text-xs text-muted-foreground">1/{pendingCount}</span>
33 ) : null}33 ) : null}
34 </div>34 </div>
35 {approval.detail ? (35 {approval.responseCapability === "not_resumable" ? (
36 <div className="mt-3 rounded-lg border border-border/65 bg-background/70 p-3">36 <p className="mt-2 text-xs text-muted-foreground">
37 <p className="text-xs font-medium text-muted-foreground">{detailLabel}</p>37 This request belonged to a provider process that is no longer available. Interrupt or
38 <pre38 restart the run to continue.
39 aria-label={detailLabel}39 </p>
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>
46 ) : null}40 ) : null}
47 </div>41 </div>
48 );42 );
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+17−7
@@ -1,4 +1,4 @@
1import { type ApprovalRequestId } from "@t3tools/contracts";1import { type RuntimeRequestId } from "@t3tools/contracts";
2import { memo, useEffect, useEffectEvent, useRef, useState } from "react";2import { memo, useEffect, useEffectEvent, useRef, useState } from "react";
3import { type PendingUserInput } from "../../session-logic";3import { type PendingUserInput } from "../../session-logic";
4import {4import {
@@ -10,7 +10,7 @@ import { cn } from "~/lib/utils"; · 5 unmodified lines
10 10
11interface PendingUserInputPanelProps {11interface PendingUserInputPanelProps {
12 pendingUserInputs: PendingUserInput[];12 pendingUserInputs: PendingUserInput[];
13 respondingRequestIds: ApprovalRequestId[];13 respondingRequestIds: RuntimeRequestId[];
14 answers: Record<string, PendingUserInputDraftAnswer>;14 answers: Record<string, PendingUserInputDraftAnswer>;
15 questionIndex: number;15 questionIndex: number;
16 onToggleOption: (questionId: string, optionLabel: string) => void;16 onToggleOption: (questionId: string, optionLabel: string) => void;
@@ -59,6 +59,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 42 unmodified lines
59}) {59}) {
60 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);60 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);
61 const activeQuestion = progress.activeQuestion;61 const activeQuestion = progress.activeQuestion;
62 const canRespond = prompt.responseCapability === "live";
63 const responseDisabled = isResponding || !canRespond;
62 const autoAdvanceTimerRef = useRef<number | null>(null);64 const autoAdvanceTimerRef = useRef<number | null>(null);
63 const onAdvanceRef = useRef(onAdvance);65 const onAdvanceRef = useRef(onAdvance);
64 const [optimisticSingleSelect, setOptimisticSingleSelect] = useState<{66 const [optimisticSingleSelect, setOptimisticSingleSelect] = useState<{
@@ -120,7 +122,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 55 unmodified lines
120 // outside editable fields. Multi-select prompts toggle options in place; single-122 // outside editable fields. Multi-select prompts toggle options in place; single-
121 // select prompts keep the existing auto-advance behavior.123 // select prompts keep the existing auto-advance behavior.
122 useEffect(() => {124 useEffect(() => {
123 if (!activeQuestion || isResponding) return;125 if (!activeQuestion || responseDisabled) return;
124 const handler = (event: globalThis.KeyboardEvent) => {126 const handler = (event: globalThis.KeyboardEvent) => {
125 if (event.metaKey || event.ctrlKey || event.altKey) return;127 if (event.metaKey || event.ctrlKey || event.altKey) return;
126 const target = event.target;128 const target = event.target;
@@ -144,7 +146,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 17 unmodified lines
144 };146 };
145 document.addEventListener("keydown", handler);147 document.addEventListener("keydown", handler);
146 return () => document.removeEventListener("keydown", handler);148 return () => document.removeEventListener("keydown", handler);
147 }, [activeQuestion, isResponding]);149 }, [activeQuestion, responseDisabled]);
148 150
149 if (!activeQuestion) {151 if (!activeQuestion) {
150 return null;152 return null;
@@ -165,6 +167,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 14 unmodified lines
165 ) : null}167 ) : null}
166 </div>168 </div>
167 <p className="text-sm text-foreground/90">{activeQuestion.question}</p>169 <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}
168 {activeQuestion.multiSelect ? (176 {activeQuestion.multiSelect ? (
169 <p className="mt-1 text-xs text-muted-foreground/65">Select one or more options.</p>177 <p className="mt-1 text-xs text-muted-foreground/65">Select one or more options.</p>
170 ) : null}178 ) : null}
@@ -182,8 +190,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 11 unmodified lines
182 isSelected190 isSelected
183 ? "border-primary/30 bg-primary/8 text-foreground"191 ? "border-primary/30 bg-primary/8 text-foreground"
184 : "border-transparent bg-muted/22 text-foreground/85 hover:border-border/45 hover:bg-muted/34",192 : "border-transparent bg-muted/22 text-foreground/85 hover:border-border/45 hover:bg-muted/34",
185 isResponding && "opacity-50 cursor-not-allowed",193 responseDisabled && "opacity-50 cursor-not-allowed",
186 !isResponding && "cursor-pointer",194 !responseDisabled && "cursor-pointer",
187 );195 );
188 const content = (196 const content = (
189 <>197 <>
@@ -211,8 +219,10 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 21 unmodified lines
211 <button219 <button
212 key={`${activeQuestion.id}:${option.label}`}220 key={`${activeQuestion.id}:${option.label}`}
213 type="button"221 type="button"
214 disabled={isResponding}222 disabled={responseDisabled}
223 aria-disabled={responseDisabled}
215 onClick={() => {224 onClick={() => {
225 if (responseDisabled) return;
216 handleOptionSelection(activeQuestion.id, option.label);226 handleOptionSelection(activeQuestion.id, option.label);
217 }}227 }}
218 className={className}228 className={className}
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+128−55
@@ -272,7 +272,7 @@ describe("deriveMessagesTimelineRows", () => { · 271 unmodified lines
272 id: "user-1" as never,272 id: "user-1" as never,
273 role: "user",273 role: "user",
274 text: "Write a poem",274 text: "Write a poem",
275 turnId: null,275 runId: null,
276 createdAt: "2026-01-01T00:00:00Z",276 createdAt: "2026-01-01T00:00:00Z",
277 updatedAt: "2026-01-01T00:00:00Z",277 updatedAt: "2026-01-01T00:00:00Z",
278 streaming: false,278 streaming: false,
@@ -286,7 +286,7 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines
286 id: "assistant-thought" as never,286 id: "assistant-thought" as never,
287 role: "assistant",287 role: "assistant",
288 text: "I should ground this first.",288 text: "I should ground this first.",
289 turnId: "turn-1" as never,289 runId: "turn-1" as never,
290 createdAt: "2026-01-01T00:00:10Z",290 createdAt: "2026-01-01T00:00:10Z",
291 updatedAt: "2026-01-01T00:00:11Z",291 updatedAt: "2026-01-01T00:00:11Z",
292 streaming: false,292 streaming: false,
@@ -300,14 +300,14 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines
300 id: "assistant-final" as never,300 id: "assistant-final" as never,
301 role: "assistant",301 role: "assistant",
302 text: "Here is the poem.",302 text: "Here is the poem.",
303 turnId: "turn-1" as never,303 runId: "turn-1" as never,
304 createdAt: "2026-01-01T00:00:20Z",304 createdAt: "2026-01-01T00:00:20Z",
305 updatedAt: "2026-01-01T00:00:30Z",305 updatedAt: "2026-01-01T00:00:30Z",
306 streaming: false,306 streaming: false,
307 },307 },
308 },308 },
309 ],309 ],
310 expandedTurnIds: new Set(["turn-1" as never]),310 expandedRunIds: new Set(["turn-1" as never]),
311 isWorking: false,311 isWorking: false,
312 activeTurnStartedAt: null,312 activeTurnStartedAt: null,
313 turnDiffSummaryByAssistantMessageId: new Map(),313 turnDiffSummaryByAssistantMessageId: new Map(),
@@ -335,7 +335,7 @@ describe("deriveMessagesTimelineRows", () => { · 21 unmodified lines
335 id: "assistant-one" as never,335 id: "assistant-one" as never,
336 role: "assistant",336 role: "assistant",
337 text: "Earlier response.",337 text: "Earlier response.",
338 turnId: "turn-1" as never,338 runId: "turn-1" as never,
339 createdAt: "2026-01-01T00:00:10Z",339 createdAt: "2026-01-01T00:00:10Z",
340 updatedAt: "2026-01-01T00:00:11Z",340 updatedAt: "2026-01-01T00:00:11Z",
341 streaming: false,341 streaming: false,
@@ -349,16 +349,16 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines
349 id: "assistant-two" as never,349 id: "assistant-two" as never,
350 role: "assistant",350 role: "assistant",
351 text: "Active response.",351 text: "Active response.",
352 turnId: "turn-2" as never,352 runId: "turn-2" as never,
353 createdAt: "2026-01-01T00:00:20Z",353 createdAt: "2026-01-01T00:00:20Z",
354 updatedAt: "2026-01-01T00:00:30Z",354 updatedAt: "2026-01-01T00:00:30Z",
355 streaming: false,355 streaming: false,
356 },356 },
357 },357 },
358 ],358 ],
359 latestTurn: {359 latestRun: {
360 turnId: "turn-2" as never,360 runId: "turn-2" as never,
361 state: "running",361 status: "running",
362 startedAt: "2026-01-01T00:00:19Z",362 startedAt: "2026-01-01T00:00:19Z",
363 completedAt: null,363 completedAt: null,
364 },364 },
@@ -379,7 +379,7 @@ describe("deriveMessagesTimelineRows", () => { · 14 unmodified lines
379 379
380 it("projects assistant diff summaries and user revert counts onto the affected rows", () => {380 it("projects assistant diff summaries and user revert counts onto the affected rows", () => {
381 const assistantTurnDiffSummary = {381 const assistantTurnDiffSummary = {
382 turnId: "turn-1" as never,382 runId: "turn-1" as never,
383 completedAt: "2026-01-01T00:00:30Z",383 completedAt: "2026-01-01T00:00:30Z",
384 assistantMessageId: "assistant-1" as never,384 assistantMessageId: "assistant-1" as never,
385 checkpointTurnCount: 2,385 checkpointTurnCount: 2,
@@ -398,7 +398,7 @@ describe("deriveMessagesTimelineRows", () => { · 12 unmodified lines
398 id: "user-1" as never,398 id: "user-1" as never,
399 role: "user",399 role: "user",
400 text: "Do the thing",400 text: "Do the thing",
401 turnId: null,401 runId: null,
402 createdAt: "2026-01-01T00:00:00Z",402 createdAt: "2026-01-01T00:00:00Z",
403 updatedAt: "2026-01-01T00:00:00Z",403 updatedAt: "2026-01-01T00:00:00Z",
404 streaming: false,404 streaming: false,
@@ -412,7 +412,7 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines
412 id: "assistant-1" as never,412 id: "assistant-1" as never,
413 role: "assistant",413 role: "assistant",
414 text: "Done",414 text: "Done",
415 turnId: "turn-1" as never,415 runId: "turn-1" as never,
416 createdAt: "2026-01-01T00:00:20Z",416 createdAt: "2026-01-01T00:00:20Z",
417 updatedAt: "2026-01-01T00:00:30Z",417 updatedAt: "2026-01-01T00:00:30Z",
418 streaming: false,418 streaming: false,
@@ -450,7 +450,7 @@ describe("deriveMessagesTimelineRows", () => { · 31 unmodified lines
450 id: "user-1" as never,450 id: "user-1" as never,
451 role: "user" as const,451 role: "user" as const,
452 text: "Build it",452 text: "Build it",
453 turnId: null,453 runId: null,
454 createdAt: "2026-01-01T00:00:00Z",454 createdAt: "2026-01-01T00:00:00Z",
455 updatedAt: "2026-01-01T00:00:00Z",455 updatedAt: "2026-01-01T00:00:00Z",
456 streaming: false,456 streaming: false,
@@ -464,7 +464,7 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines
464 id: "assistant-thought" as never,464 id: "assistant-thought" as never,
465 role: "assistant" as const,465 role: "assistant" as const,
466 text: "Looking around first.",466 text: "Looking around first.",
467 turnId: "turn-1" as never,467 runId: "turn-1" as never,
468 createdAt: "2026-01-01T00:00:05Z",468 createdAt: "2026-01-01T00:00:05Z",
469 updatedAt: "2026-01-01T00:00:06Z",469 updatedAt: "2026-01-01T00:00:06Z",
470 streaming: false,470 streaming: false,
@@ -477,7 +477,7 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines
477 entry: {477 entry: {
478 id: "work-1",478 id: "work-1",
479 createdAt: "2026-01-01T00:00:08Z",479 createdAt: "2026-01-01T00:00:08Z",
480 turnId: "turn-1" as never,480 runId: "turn-1" as never,
481 label: "Ran command",481 label: "Ran command",
482 tone: "tool" as const,482 tone: "tool" as const,
483 },483 },
@@ -490,7 +490,7 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines
490 id: "assistant-final" as never,490 id: "assistant-final" as never,
491 role: "assistant" as const,491 role: "assistant" as const,
492 text: "Done",492 text: "Done",
493 turnId: "turn-1" as never,493 runId: "turn-1" as never,
494 createdAt: "2026-01-01T00:00:20Z",494 createdAt: "2026-01-01T00:00:20Z",
495 updatedAt: "2026-01-01T00:00:22Z",495 updatedAt: "2026-01-01T00:00:22Z",
496 streaming: false,496 streaming: false,
@@ -510,7 +510,7 @@ describe("deriveMessagesTimelineRows", () => { · 13 unmodified lines
510 (row): row is Extract<(typeof collapsedRows)[number], { kind: "turn-fold" }> =>510 (row): row is Extract<(typeof collapsedRows)[number], { kind: "turn-fold" }> =>
511 row.kind === "turn-fold",511 row.kind === "turn-fold",
512 );512 );
513 expect(foldRow?.turnId).toBe("turn-1");513 expect(foldRow?.runId).toBe("turn-1");
514 expect(foldRow?.expanded).toBe(false);514 expect(foldRow?.expanded).toBe(false);
515 // User message boundary (00:00:00) → terminal message updatedAt (00:00:22).515 // User message boundary (00:00:00) → terminal message updatedAt (00:00:22).
516 expect(foldRow?.label).toBe("Worked for 22s");516 expect(foldRow?.label).toBe("Worked for 22s");
@@ -522,7 +522,7 @@ describe("deriveMessagesTimelineRows", () => { · 5 unmodified lines
522 522
523 const expandedRows = deriveMessagesTimelineRows({523 const expandedRows = deriveMessagesTimelineRows({
524 timelineEntries,524 timelineEntries,
525 expandedTurnIds: new Set(["turn-1" as never]),525 expandedRunIds: new Set(["turn-1" as never]),
526 isWorking: false,526 isWorking: false,
527 activeTurnStartedAt: null,527 activeTurnStartedAt: null,
528 turnDiffSummaryByAssistantMessageId: new Map(),528 turnDiffSummaryByAssistantMessageId: new Map(),
@@ -556,7 +556,7 @@ describe("deriveMessagesTimelineRows", () => { · 27 unmodified lines
556 id: "user-1" as never,556 id: "user-1" as never,
557 role: "user" as const,557 role: "user" as const,
558 text: "do it once more",558 text: "do it once more",
559 turnId: null,559 runId: null,
560 createdAt: "2026-01-01T00:00:00Z",560 createdAt: "2026-01-01T00:00:00Z",
561 updatedAt: "2026-01-01T00:00:00Z",561 updatedAt: "2026-01-01T00:00:00Z",
562 streaming: false,562 streaming: false,
@@ -570,7 +570,7 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines
570 id: "assistant-commentary" as never,570 id: "assistant-commentary" as never,
571 role: "assistant" as const,571 role: "assistant" as const,
572 text: "Kicking off call 1.",572 text: "Kicking off call 1.",
573 turnId: "turn-1" as never,573 runId: "turn-1" as never,
574 createdAt: "2026-01-01T00:00:09Z",574 createdAt: "2026-01-01T00:00:09Z",
575 updatedAt: "2026-01-01T00:00:09Z",575 updatedAt: "2026-01-01T00:00:09Z",
576 streaming: false,576 streaming: false,
@@ -583,7 +583,7 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines
583 entry: {583 entry: {
584 id: "work-1",584 id: "work-1",
585 createdAt: "2026-01-01T00:00:12Z",585 createdAt: "2026-01-01T00:00:12Z",
586 turnId: "turn-1" as never,586 runId: "turn-1" as never,
587 label: "Ran command",587 label: "Ran command",
588 tone: "tool" as const,588 tone: "tool" as const,
589 },589 },
@@ -596,7 +596,7 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines
596 id: "user-2" as never,596 id: "user-2" as never,
597 role: "user" as const,597 role: "user" as const,
598 text: "actually do 15",598 text: "actually do 15",
599 turnId: null,599 runId: null,
600 createdAt: "2026-01-01T00:00:14Z",600 createdAt: "2026-01-01T00:00:14Z",
601 updatedAt: "2026-01-01T00:00:14Z",601 updatedAt: "2026-01-01T00:00:14Z",
602 streaming: false,602 streaming: false,
@@ -610,16 +610,16 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines
610 id: "assistant-next" as never,610 id: "assistant-next" as never,
611 role: "assistant" as const,611 role: "assistant" as const,
612 text: "One down — adjusting.",612 text: "One down — adjusting.",
613 turnId: "turn-2" as never,613 runId: "turn-2" as never,
614 createdAt: "2026-01-01T00:00:17Z",614 createdAt: "2026-01-01T00:00:17Z",
615 updatedAt: "2026-01-01T00:00:17Z",615 updatedAt: "2026-01-01T00:00:17Z",
616 streaming: true,616 streaming: true,
617 },617 },
618 },618 },
619 ],619 ],
620 latestTurn: {620 latestRun: {
621 turnId: "turn-2" as never,621 runId: "turn-2" as never,
622 state: "running",622 status: "running",
623 startedAt: "2026-01-01T00:00:14Z",623 startedAt: "2026-01-01T00:00:14Z",
624 completedAt: null,624 completedAt: null,
625 },625 },
@@ -634,7 +634,7 @@ describe("deriveMessagesTimelineRows", () => { · 8 unmodified lines
634 row.kind === "turn-fold",634 row.kind === "turn-fold",
635 );635 );
636 // User message (00:00:00) → trailing work entry (00:00:12).636 // 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");
638 expect(foldRow?.label).toBe("Worked for 12s");638 expect(foldRow?.label).toBe("Worked for 12s");
639 });639 });
640 640
@@ -648,15 +648,15 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines
648 entry: {648 entry: {
649 id: "work-1",649 id: "work-1",
650 createdAt: "2026-01-01T00:00:05Z",650 createdAt: "2026-01-01T00:00:05Z",
651 turnId: "turn-1" as never,651 runId: "turn-1" as never,
652 label: "Ran command",652 label: "Ran command",
653 tone: "tool" as const,653 tone: "tool" as const,
654 },654 },
655 },655 },
656 ],656 ],
657 latestTurn: {657 latestRun: {
658 turnId: "turn-1" as never,658 runId: "turn-1" as never,
659 state: "interrupted",659 status: "interrupted",
660 startedAt: "2026-01-01T00:00:00Z",660 startedAt: "2026-01-01T00:00:00Z",
661 completedAt: "2026-01-01T00:00:47Z",661 completedAt: "2026-01-01T00:00:47Z",
662 },662 },
@@ -669,15 +669,88 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines
669 expect(rows).toEqual([669 expect(rows).toEqual([
670 expect.objectContaining({670 expect.objectContaining({
671 kind: "turn-fold",671 kind: "turn-fold",
672 turnId: "turn-1",672 runId: "turn-1",
673 label: "You stopped after 47s",673 label: "You stopped after 47s",
674 expanded: false,674 expanded: false,
675 }),675 }),
676 ]);676 ]);
677 });677 });
678 678
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
679 it("keeps the previous turn folded while a newly sent message awaits its turn", () => {752 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 the753 // Right after send, isWorking is true but latestRun still points at the
681 // previous, settled turn — it must stay folded through that window.754 // previous, settled turn — it must stay folded through that window.
682 const rows = deriveMessagesTimelineRows({755 const rows = deriveMessagesTimelineRows({
683 timelineEntries: [756 timelineEntries: [
@@ -688,7 +761,7 @@ describe("deriveMessagesTimelineRows", () => { · 4 unmodified lines
688 entry: {761 entry: {
689 id: "work-1",762 id: "work-1",
690 createdAt: "2026-01-01T00:00:05Z",763 createdAt: "2026-01-01T00:00:05Z",
691 turnId: "turn-1" as never,764 runId: "turn-1" as never,
692 label: "Ran command",765 label: "Ran command",
693 tone: "tool" as const,766 tone: "tool" as const,
694 },767 },
@@ -701,7 +774,7 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines
701 id: "assistant-final" as never,774 id: "assistant-final" as never,
702 role: "assistant",775 role: "assistant",
703 text: "Done",776 text: "Done",
704 turnId: "turn-1" as never,777 runId: "turn-1" as never,
705 createdAt: "2026-01-01T00:00:20Z",778 createdAt: "2026-01-01T00:00:20Z",
706 updatedAt: "2026-01-01T00:00:22Z",779 updatedAt: "2026-01-01T00:00:22Z",
707 streaming: false,780 streaming: false,
@@ -715,16 +788,16 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines
715 id: "user-followup" as never,788 id: "user-followup" as never,
716 role: "user",789 role: "user",
717 text: "yooo",790 text: "yooo",
718 turnId: null,791 runId: null,
719 createdAt: "2026-01-01T00:01:00Z",792 createdAt: "2026-01-01T00:01:00Z",
720 updatedAt: "2026-01-01T00:01:00Z",793 updatedAt: "2026-01-01T00:01:00Z",
721 streaming: false,794 streaming: false,
722 },795 },
723 },796 },
724 ],797 ],
725 latestTurn: {798 latestRun: {
726 turnId: "turn-1" as never,799 runId: "turn-1" as never,
727 state: "completed",800 status: "completed",
728 startedAt: "2026-01-01T00:00:00Z",801 startedAt: "2026-01-01T00:00:00Z",
729 completedAt: "2026-01-01T00:00:22Z",802 completedAt: "2026-01-01T00:00:22Z",
730 },803 },
@@ -755,7 +828,7 @@ describe("deriveMessagesTimelineRows", () => { · 24 unmodified lines
755 id: "assistant-thought" as never,828 id: "assistant-thought" as never,
756 role: "assistant",829 role: "assistant",
757 text: "Working on it.",830 text: "Working on it.",
758 turnId: "turn-1" as never,831 runId: "turn-1" as never,
759 createdAt: "2026-01-01T00:00:05Z",832 createdAt: "2026-01-01T00:00:05Z",
760 updatedAt: "2026-01-01T00:00:06Z",833 updatedAt: "2026-01-01T00:00:06Z",
761 streaming: false,834 streaming: false,
@@ -768,15 +841,15 @@ describe("deriveMessagesTimelineRows", () => { · 6 unmodified lines
768 entry: {841 entry: {
769 id: "work-1",842 id: "work-1",
770 createdAt: "2026-01-01T00:00:08Z",843 createdAt: "2026-01-01T00:00:08Z",
771 turnId: "turn-1" as never,844 runId: "turn-1" as never,
772 label: "Ran command",845 label: "Ran command",
773 tone: "tool" as const,846 tone: "tool" as const,
774 },847 },
775 },848 },
776 ],849 ],
777 latestTurn: {850 latestRun: {
778 turnId: "turn-1" as never,851 runId: "turn-1" as never,
779 state: "running",852 status: "running",
780 startedAt: "2026-01-01T00:00:00Z",853 startedAt: "2026-01-01T00:00:00Z",
781 completedAt: null,854 completedAt: null,
782 },855 },
@@ -866,7 +939,7 @@ describe("deriveMessagesTimelineRows", () => { · 83 unmodified lines
866 id: "assistant-thought" as never,939 id: "assistant-thought" as never,
867 role: "assistant",940 role: "assistant",
868 text: "Checking first.",941 text: "Checking first.",
869 turnId: "turn-1" as never,942 runId: "turn-1" as never,
870 createdAt: "2026-01-01T00:00:10Z",943 createdAt: "2026-01-01T00:00:10Z",
871 updatedAt: "2026-01-01T00:00:11Z",944 updatedAt: "2026-01-01T00:00:11Z",
872 streaming: false,945 streaming: false,
@@ -880,14 +953,14 @@ describe("deriveMessagesTimelineRows", () => { · 7 unmodified lines
880 id: "assistant-final" as never,953 id: "assistant-final" as never,
881 role: "assistant",954 role: "assistant",
882 text: "Done.",955 text: "Done.",
883 turnId: "turn-1" as never,956 runId: "turn-1" as never,
884 createdAt: "2026-01-01T00:00:20Z",957 createdAt: "2026-01-01T00:00:20Z",
885 updatedAt: "2026-01-01T00:00:30Z",958 updatedAt: "2026-01-01T00:00:30Z",
886 streaming: false,959 streaming: false,
887 },960 },
888 },961 },
889 ],962 ],
890 expandedTurnIds: new Set(["turn-1" as never]),963 expandedRunIds: new Set(["turn-1" as never]),
891 isWorking: false,964 isWorking: false,
892 activeTurnStartedAt: null,965 activeTurnStartedAt: null,
893 turnDiffSummaryByAssistantMessageId: new Map(),966 turnDiffSummaryByAssistantMessageId: new Map(),
@@ -913,16 +986,16 @@ describe("deriveMessagesTimelineRows", () => { · 19 unmodified lines
913 id: "assistant-thought" as never,986 id: "assistant-thought" as never,
914 role: "assistant",987 role: "assistant",
915 text: "Working on it.",988 text: "Working on it.",
916 turnId: "turn-1" as never,989 runId: "turn-1" as never,
917 createdAt: "2026-01-01T00:00:10Z",990 createdAt: "2026-01-01T00:00:10Z",
918 updatedAt: "2026-01-01T00:00:11Z",991 updatedAt: "2026-01-01T00:00:11Z",
919 streaming: false,992 streaming: false,
920 },993 },
921 },994 },
922 ],995 ],
923 latestTurn: {996 latestRun: {
924 turnId: "turn-1" as never,997 runId: "turn-1" as never,
925 state: "running",998 status: "running",
926 startedAt: "2026-01-01T00:00:00Z",999 startedAt: "2026-01-01T00:00:00Z",
927 completedAt: null,1000 completedAt: null,
928 },1001 },
@@ -1019,7 +1092,7 @@ describe("computeStableMessagesTimelineRows", () => { · 90 unmodified lines
1019 id: "user-1" as never,1092 id: "user-1" as never,
1020 role: "user" as const,1093 role: "user" as const,
1021 text: "First",1094 text: "First",
1022 turnId: null,1095 runId: null,
1023 createdAt: "2026-01-01T00:00:00Z",1096 createdAt: "2026-01-01T00:00:00Z",
1024 updatedAt: "2026-01-01T00:00:00Z",1097 updatedAt: "2026-01-01T00:00:00Z",
1025 streaming: false,1098 streaming: false,
@@ -1028,7 +1101,7 @@ describe("computeStableMessagesTimelineRows", () => { · 2 unmodified lines
1028 id: "user-2" as never,1101 id: "user-2" as never,
1029 role: "user" as const,1102 role: "user" as const,
1030 text: "Second",1103 text: "Second",
1031 turnId: null,1104 runId: null,
1032 createdAt: "2026-01-01T00:00:10Z",1105 createdAt: "2026-01-01T00:00:10Z",
1033 updatedAt: "2026-01-01T00:00:10Z",1106 updatedAt: "2026-01-01T00:00:10Z",
1034 streaming: false,1107 streaming: false,
@@ -1124,7 +1197,7 @@ describe("computeStableMessagesTimelineRows", () => { · 89 unmodified lines
1124 id: "user-1" as never,1197 id: "user-1" as never,
1125 role: "user" as const,1198 role: "user" as const,
1126 text: "First",1199 text: "First",
1127 turnId: null,1200 runId: null,
1128 createdAt: "2026-01-01T00:00:00Z",1201 createdAt: "2026-01-01T00:00:00Z",
1129 updatedAt: "2026-01-01T00:00:00Z",1202 updatedAt: "2026-01-01T00:00:00Z",
1130 streaming: false,1203 streaming: false,
@@ -1133,7 +1206,7 @@ describe("computeStableMessagesTimelineRows", () => { · 2 unmodified lines
1133 id: "user-2" as never,1206 id: "user-2" as never,
1134 role: "user" as const,1207 role: "user" as const,
1135 text: "Second",1208 text: "Second",
1136 turnId: null,1209 runId: null,
1137 createdAt: "2026-01-01T00:00:10Z",1210 createdAt: "2026-01-01T00:00:10Z",
1138 updatedAt: "2026-01-01T00:00:10Z",1211 updatedAt: "2026-01-01T00:00:10Z",
1139 streaming: false,1212 streaming: false,
apps/web/src/components/chat/MessagesTimeline.logic.ts+78−51
@@ -7,7 +7,12 @@ import { · 6 unmodified lines
7 type WorkLogEntry,7 type WorkLogEntry,
8} from "../../session-logic";8} from "../../session-logic";
9import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";9import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
10import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts";10import {
11 type MessageId,
12 type OrchestrationV2ProjectedTurnItem,
13 type RunId,
14} from "@t3tools/contracts";
15import type { ThreadRunSummary } from "@t3tools/client-runtime/state/shell";
11 16
12export const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;17export const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
13export const TIMELINE_MINIMAP_ITEM_SPACING = 8;18export const TIMELINE_MINIMAP_ITEM_SPACING = 8;
@@ -128,9 +133,9 @@ export interface TimelineDurationMessage { · 114 unmodified lines
128 streaming: boolean;133 streaming: boolean;
129}134}
130 135
131export type TimelineLatestTurn = Pick<136export type TimelineLatestRun = Pick<
132 OrchestrationLatestTurn,137 ThreadRunSummary,
133 "turnId" | "state" | "startedAt" | "completedAt"138 "runId" | "status" | "startedAt" | "completedAt"
134>;139>;
135 140
136export type MessagesTimelineRow =141export type MessagesTimelineRow =
@@ -153,7 +158,7 @@ export type MessagesTimelineRow = · 16 unmodified lines
153 kind: "turn-fold";158 kind: "turn-fold";
154 id: string;159 id: string;
155 createdAt: string;160 createdAt: string;
156 turnId: TurnId;161 runId: RunId;
157 label: string;162 label: string;
158 expanded: boolean;163 expanded: boolean;
159 }164 }
@@ -169,6 +174,12 @@ export type MessagesTimelineRow = · 9 unmodified lines
169 assistantTurnDiffSummary?: TurnDiffSummary | undefined;174 assistantTurnDiffSummary?: TurnDiffSummary | undefined;
170 revertTurnCount?: number | undefined;175 revertTurnCount?: number | undefined;
171 }176 }
177 | {
178 kind: "event";
179 id: string;
180 createdAt: string;
181 projectedItem: OrchestrationV2ProjectedTurnItem;
182 }
172 | {183 | {
173 kind: "proposed-plan";184 kind: "proposed-plan";
174 id: string;185 id: string;
@@ -238,8 +249,8 @@ function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray<Timeli · 63 unmodified lines
238 continue;249 continue;
239 }250 }
240 251
241 const responseKey = message.turnId252 const responseKey = message.runId
242 ? `turn:${message.turnId}`253 ? `turn:${message.runId}`
243 : `unkeyed:${nullTurnResponseIndex}`;254 : `unkeyed:${nullTurnResponseIndex}`;
244 lastAssistantMessageIdByResponseKey.set(responseKey, message.id);255 lastAssistantMessageIdByResponseKey.set(responseKey, message.id);
245 }256 }
@@ -248,7 +259,7 @@ function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray<Timeli · 2 unmodified lines
248}259}
249 260
250interface TurnFold {261interface TurnFold {
251 turnId: TurnId;262 runId: RunId;
252 anchorEntryId: string;263 anchorEntryId: string;
253 createdAt: string;264 createdAt: string;
254 hiddenEntryIds: ReadonlySet<string>;265 hiddenEntryIds: ReadonlySet<string>;
@@ -263,18 +274,16 @@ interface TurnFold { · 8 unmodified lines
263 * user sends a message, the previous turn is still the "active" one until the274 * user sends a message, the previous turn is still the "active" one until the
264 * server creates the new turn, and folding must not flicker through that window.275 * server creates the new turn, and folding must not flicker through that window.
265 */276 */
266function deriveUnsettledTurnId(277function deriveUnsettledRunId(latestRun: TimelineLatestRun | null): RunId | null {
267 latestTurn: TimelineLatestTurn | null,278 if (!latestRun) {
268 runningTurnId: TurnId | null,
269): TurnId | null {
270 if (runningTurnId !== null) {
271 return runningTurnId;
272 }
273 if (!latestTurn) {
274 return null;279 return null;
275 }280 }
276 const isSettled = latestTurn.completedAt !== null && latestTurn.state !== "running";281 const isSettled =
277 return isSettled ? null : latestTurn.turnId;282 latestRun.completedAt !== null &&
283 latestRun.status !== "running" &&
284 latestRun.status !== "starting" &&
285 latestRun.status !== "waiting";
286 return isSettled ? null : latestRun.runId;
278}287}
279 288
280/**289/**
@@ -285,9 +294,21 @@ function deriveUnsettledTurnId( · 4 unmodified lines
285function deriveTurnFolds(input: {294function deriveTurnFolds(input: {
286 timelineEntries: ReadonlyArray<TimelineEntry>;295 timelineEntries: ReadonlyArray<TimelineEntry>;
287 terminalAssistantMessageIds: ReadonlySet<string>;296 terminalAssistantMessageIds: ReadonlySet<string>;
288 latestTurn: TimelineLatestTurn | null;297 latestRun: TimelineLatestRun | null;
289 unsettledTurnId: TurnId | null;298 unsettledRunId: RunId | null;
290}): ReadonlyMap<string, TurnFold> {299}): 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
291 interface TurnGroup {312 interface TurnGroup {
292 entries: Array<TimelineEntry>;313 entries: Array<TimelineEntry>;
293 terminalEntry: Extract<TimelineEntry, { kind: "message" }> | null;314 terminalEntry: Extract<TimelineEntry, { kind: "message" }> | null;
@@ -300,7 +321,7 @@ function deriveTurnFolds(input: { · 6 unmodified lines
300 */321 */
301 startBoundary: string | null;322 startBoundary: string | null;
302 }323 }
303 const groupsByTurnId = new Map<TurnId, TurnGroup>();324 const groupsByRunId = new Map<RunId, TurnGroup>();
304 325
305 let pendingUserBoundary: string | null = null;326 let pendingUserBoundary: string | null = null;
306 for (const entry of input.timelineEntries) {327 for (const entry of input.timelineEntries) {
@@ -308,16 +329,16 @@ function deriveTurnFolds(input: { · 1 unmodified line
308 pendingUserBoundary = entry.message.createdAt;329 pendingUserBoundary = entry.message.createdAt;
309 continue;330 continue;
310 }331 }
311 const turnId =332 const runId =
312 entry.kind === "message" && entry.message.role === "assistant"333 entry.kind === "message" && entry.message.role === "assistant"
313 ? (entry.message.turnId ?? null)334 ? (entry.message.runId ?? null)
314 : entry.kind === "work"335 : entry.kind === "work"
315 ? (entry.entry.turnId ?? null)336 ? (entry.entry.runId ?? null)
316 : null;337 : null;
317 if (!turnId) {338 if (!runId) {
318 continue;339 continue;
319 }340 }
320 let group = groupsByTurnId.get(turnId);341 let group = groupsByRunId.get(runId);
321 if (!group) {342 if (!group) {
322 group = {343 group = {
323 entries: [],344 entries: [],
@@ -329,7 +350,7 @@ function deriveTurnFolds(input: { · 5 unmodified lines
329 startBoundary: pendingUserBoundary,350 startBoundary: pendingUserBoundary,
330 };351 };
331 pendingUserBoundary = null;352 pendingUserBoundary = null;
332 groupsByTurnId.set(turnId, group);353 groupsByRunId.set(runId, group);
333 }354 }
334 group.entries.push(entry);355 group.entries.push(entry);
335 if (entry.kind === "message") {356 if (entry.kind === "message") {
@@ -343,8 +364,8 @@ function deriveTurnFolds(input: { · 7 unmodified lines
343 }364 }
344 365
345 const foldsByAnchorEntryId = new Map<string, TurnFold>();366 const foldsByAnchorEntryId = new Map<string, TurnFold>();
346 for (const [turnId, group] of groupsByTurnId) {367 for (const [runId, group] of groupsByRunId) {
347 if (turnId === input.unsettledTurnId) {368 if (runId === input.unsettledRunId || interruptedRunIds.has(runId)) {
348 continue;369 continue;
349 }370 }
350 if (group.hasStreamingMessage) {371 if (group.hasStreamingMessage) {
@@ -367,16 +388,14 @@ function deriveTurnFolds(input: { · 16 unmodified lines
367 }388 }
368 389
369 const isLatestInterruptedTurn =390 const isLatestInterruptedTurn =
370 input.latestTurn?.turnId === turnId && input.latestTurn.state === "interrupted";391 input.latestRun?.runId === runId && input.latestRun.status === "interrupted";
371 // A turn cut short by a steer leaves trailing work entries behind its392 // A turn cut short by a steer leaves trailing work entries behind its
372 // terminal message — take whichever ended last.393 // terminal message — take whichever ended last.
373 const lastEntryEnd =394 const lastEntryEnd =
374 lastEntry.kind === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt;395 lastEntry.kind === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt;
375 const elapsedMs =396 const elapsedMs =
376 input.latestTurn?.turnId === turnId &&397 input.latestRun?.runId === runId && input.latestRun.startedAt && input.latestRun.completedAt
377 input.latestTurn.startedAt &&398 ? computeElapsedMs(input.latestRun.startedAt, input.latestRun.completedAt)
378 input.latestTurn.completedAt
379 ? computeElapsedMs(input.latestTurn.startedAt, input.latestTurn.completedAt)
380 : computeElapsedMs(399 : computeElapsedMs(
381 group.startBoundary ?? firstEntry.createdAt,400 group.startBoundary ?? firstEntry.createdAt,
382 maxIsoTimestamp(group.terminalEntry?.message.updatedAt ?? null, lastEntryEnd) ??401 maxIsoTimestamp(group.terminalEntry?.message.updatedAt ?? null, lastEntryEnd) ??
@@ -392,7 +411,7 @@ function deriveTurnFolds(input: { · 9 unmodified lines
392 : "Worked";411 : "Worked";
393 412
394 foldsByAnchorEntryId.set(firstEntry.id, {413 foldsByAnchorEntryId.set(firstEntry.id, {
395 turnId,414 runId,
396 anchorEntryId: firstEntry.id,415 anchorEntryId: firstEntry.id,
397 createdAt: firstEntry.createdAt,416 createdAt: firstEntry.createdAt,
398 hiddenEntryIds,417 hiddenEntryIds,
@@ -404,10 +423,8 @@ function deriveTurnFolds(input: { · 5 unmodified lines
404 423
405export function deriveMessagesTimelineRows(input: {424export function deriveMessagesTimelineRows(input: {
406 timelineEntries: ReadonlyArray<TimelineEntry>;425 timelineEntries: ReadonlyArray<TimelineEntry>;
407 latestTurn?: TimelineLatestTurn | null;426 latestRun?: TimelineLatestRun | null;
408 runningTurnId?: TurnId | null;427 expandedRunIds?: ReadonlySet<RunId>;
409 expandedTurnIds?: ReadonlySet<TurnId>;
410 expandedWorkGroupIds?: ReadonlySet<string>;
411 isWorking: boolean;428 isWorking: boolean;
412 activeTurnStartedAt: string | null;429 activeTurnStartedAt: string | null;
413 turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>;430 turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>;
@@ -418,19 +435,16 @@ export function deriveMessagesTimelineRows(input: { · 4 unmodified lines
418 input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),435 input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),
419 );436 );
420 const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(input.timelineEntries);437 const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(input.timelineEntries);
421 const unsettledTurnId = deriveUnsettledTurnId(438 const unsettledRunId = deriveUnsettledRunId(input.latestRun ?? null);
422 input.latestTurn ?? null,
423 input.runningTurnId ?? null,
424 );
425 const foldsByAnchorEntryId = deriveTurnFolds({439 const foldsByAnchorEntryId = deriveTurnFolds({
426 timelineEntries: input.timelineEntries,440 timelineEntries: input.timelineEntries,
427 terminalAssistantMessageIds,441 terminalAssistantMessageIds,
428 latestTurn: input.latestTurn ?? null,442 latestRun: input.latestRun ?? null,
429 unsettledTurnId,443 unsettledRunId,
430 });444 });
431 const collapsedEntryIds = new Set<string>();445 const collapsedEntryIds = new Set<string>();
432 for (const fold of foldsByAnchorEntryId.values()) {446 for (const fold of foldsByAnchorEntryId.values()) {
433 if (!input.expandedTurnIds?.has(fold.turnId)) {447 if (!input.expandedRunIds?.has(fold.runId)) {
434 for (const entryId of fold.hiddenEntryIds) {448 for (const entryId of fold.hiddenEntryIds) {
435 collapsedEntryIds.add(entryId);449 collapsedEntryIds.add(entryId);
436 }450 }
@@ -447,11 +461,11 @@ export function deriveMessagesTimelineRows(input: { · 10 unmodified lines
447 if (turnFold) {461 if (turnFold) {
448 nextRows.push({462 nextRows.push({
449 kind: "turn-fold",463 kind: "turn-fold",
450 id: `turn-fold:${turnFold.turnId}`,464 id: `turn-fold:${turnFold.runId}`,
451 createdAt: turnFold.createdAt,465 createdAt: turnFold.createdAt,
452 turnId: turnFold.turnId,466 runId: turnFold.runId,
453 label: turnFold.label,467 label: turnFold.label,
454 expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false,468 expanded: input.expandedRunIds?.has(turnFold.runId) ?? false,
455 });469 });
456 }470 }
457 471
@@ -527,10 +541,20 @@ export function deriveMessagesTimelineRows(input: { · 69 unmodified lines
527 continue;541 continue;
528 }542 }
529 543
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
530 const assistantTurnStillInProgress =554 const assistantTurnStillInProgress =
531 timelineEntry.message.role === "assistant" &&555 timelineEntry.message.role === "assistant" &&
532 unsettledTurnId !== null &&556 unsettledRunId !== null &&
533 timelineEntry.message.turnId === unsettledTurnId;557 timelineEntry.message.runId === unsettledRunId;
534 558
535 const durationStart =559 const durationStart =
536 durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt;560 durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt;
@@ -610,6 +634,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean · 73 unmodified lines
610 case "proposed-plan":634 case "proposed-plan":
611 return a.proposedPlan === (b as typeof a).proposedPlan;635 return a.proposedPlan === (b as typeof a).proposedPlan;
612 636
637 case "event":
638 return a.projectedItem === (b as typeof a).projectedItem;
639
613 case "work":640 case "work":
614 return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries);641 return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries);
615 642
apps/web/src/components/chat/MessagesTimeline.test.tsx+116−6
@@ -176,8 +176,7 @@ function buildProps() { · 175 unmodified lines
176 activeTurnInProgress: false,176 activeTurnInProgress: false,
177 activeTurnStartedAt: null,177 activeTurnStartedAt: null,
178 listRef: createRef<LegendListRef | null>(),178 listRef: createRef<LegendListRef | null>(),
179 latestTurn: null,179 latestRun: null,
180 runningTurnId: null,
181 turnDiffSummaryByAssistantMessageId: new Map(),180 turnDiffSummaryByAssistantMessageId: new Map(),
182 routeThreadKey: "environment-local:thread-1",181 routeThreadKey: "environment-local:thread-1",
183 onOpenTurnDiff: () => {},182 onOpenTurnDiff: () => {},
@@ -214,7 +213,7 @@ function buildUserTimelineEntry(text: string) { · 30 unmodified lines
214 id: MessageId.make("message-1"),213 id: MessageId.make("message-1"),
215 role: "user" as const,214 role: "user" as const,
216 text,215 text,
217 turnId: null,216 runId: null,
218 createdAt: MESSAGE_CREATED_AT,217 createdAt: MESSAGE_CREATED_AT,
219 updatedAt: MESSAGE_CREATED_AT,218 updatedAt: MESSAGE_CREATED_AT,
220 streaming: false,219 streaming: false,
@@ -527,7 +526,116 @@ describe("MessagesTimeline", () => { · 306 unmodified lines
527 expect(markup).toContain("Work Log");526 expect(markup).toContain("Work Log");
528 });527 });
529 528
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");
531 const markup = renderToStaticMarkup(639 const markup = renderToStaticMarkup(
532 <MessagesTimeline640 <MessagesTimeline
533 {...buildProps()}641 {...buildProps()}
@@ -541,6 +649,8 @@ describe("MessagesTimeline", () => { · 7 unmodified lines
541 createdAt: "2026-03-17T19:12:28.000Z",649 createdAt: "2026-03-17T19:12:28.000Z",
542 label: "Updated files",650 label: "Updated files",
543 tone: "tool",651 tone: "tool",
652 itemType: "file_change",
653 toolLifecycleStatus: "completed",
544 changedFiles: ["C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"],654 changedFiles: ["C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"],
545 },655 },
546 },656 },
@@ -575,7 +685,7 @@ describe("MessagesTimeline", () => { · 28 unmodified lines
575 "```",685 "```",
576 "</review_comment>",686 "</review_comment>",
577 ].join("\n"),687 ].join("\n"),
578 turnId: null,688 runId: null,
579 createdAt: "2026-03-17T19:12:28.000Z",689 createdAt: "2026-03-17T19:12:28.000Z",
580 updatedAt: "2026-03-17T19:12:28.000Z",690 updatedAt: "2026-03-17T19:12:28.000Z",
581 streaming: false,691 streaming: false,
@@ -614,7 +724,7 @@ describe("MessagesTimeline", () => { · 32 unmodified lines
614 "```",724 "```",
615 "</review_comment>",725 "</review_comment>",
616 ].join("\n"),726 ].join("\n"),
617 turnId: null,727 runId: null,
618 createdAt: "2026-03-17T19:12:28.000Z",728 createdAt: "2026-03-17T19:12:28.000Z",
619 updatedAt: "2026-03-17T19:12:28.000Z",729 updatedAt: "2026-03-17T19:12:28.000Z",
620 streaming: false,730 streaming: false,
apps/web/src/components/chat/MessagesTimeline.tsx+283−75
@@ -1,9 +1,10 @@
1import {1import {
2 type EnvironmentId,2 type EnvironmentId,
3 type MessageId,3 type MessageId,
4 type OrchestrationV2TurnItem,
4 type ScopedThreadRef,5 type ScopedThreadRef,
5 type ServerProviderSkill,6 type ServerProviderSkill,
6 type TurnId,7 type RunId,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
8import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";9import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
9import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";10import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
@@ -78,8 +79,7 @@ import { · 68 unmodified lines
78 resolveTimelineMinimapTopPercent,79 resolveTimelineMinimapTopPercent,
79 type StableMessagesTimelineRowsState,80 type StableMessagesTimelineRowsState,
80 type MessagesTimelineRow,81 type MessagesTimelineRow,
81 TIMELINE_MINIMAP_MIN_ITEMS,82 type TimelineLatestRun,
82 type TimelineLatestTurn,
83} from "./MessagesTimeline.logic";83} from "./MessagesTimeline.logic";
84import { TerminalContextInlineChip } from "./TerminalContextInlineChip";84import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
85import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";85import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
@@ -132,9 +132,8 @@ interface TimelineRowSharedState { · 46 unmodified lines
132 activeThreadEnvironmentId: EnvironmentId;132 activeThreadEnvironmentId: EnvironmentId;
133 onRevertUserMessage: (messageId: MessageId) => void;133 onRevertUserMessage: (messageId: MessageId) => void;
134 onImageExpand: (preview: ExpandedImagePreview) => void;134 onImageExpand: (preview: ExpandedImagePreview) => void;
135 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;135 onOpenTurnDiff: (runId: RunId, filePath?: string) => void;
136 onToggleTurnFold: (turnId: TurnId) => void;136 onToggleTurnFold: (runId: RunId) => void;
137 onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void;
138}137}
139 138
140interface TimelineRowActivityState {139interface TimelineRowActivityState {
@@ -161,11 +160,10 @@ interface MessagesTimelineProps { · 20 unmodified lines
161 activeTurnStartedAt: string | null;160 activeTurnStartedAt: string | null;
162 listRef: React.RefObject<LegendListRef | null>;161 listRef: React.RefObject<LegendListRef | null>;
163 timelineEntries: ReturnType<typeof deriveTimelineEntries>;162 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
164 latestTurn: TimelineLatestTurn | null;163 latestRun: TimelineLatestRun | null;
165 runningTurnId: TurnId | null;
166 turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>;164 turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>;
167 routeThreadKey: string;165 routeThreadKey: string;
168 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;166 onOpenTurnDiff: (runId: RunId, filePath?: string) => void;
169 revertTurnCountByUserMessageId: Map<MessageId, number>;167 revertTurnCountByUserMessageId: Map<MessageId, number>;
170 onRevertUserMessage: (messageId: MessageId) => void;168 onRevertUserMessage: (messageId: MessageId) => void;
171 isRevertingCheckpoint: boolean;169 isRevertingCheckpoint: boolean;
@@ -196,8 +194,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 24 unmodified lines
196 activeTurnStartedAt,194 activeTurnStartedAt,
197 listRef,195 listRef,
198 timelineEntries,196 timelineEntries,
199 latestTurn,197 latestRun,
200 runningTurnId,
201 turnDiffSummaryByAssistantMessageId,198 turnDiffSummaryByAssistantMessageId,
202 routeThreadKey,199 routeThreadKey,
203 onOpenTurnDiff,200 onOpenTurnDiff,
@@ -220,17 +217,23 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 16 unmodified lines
220 hideEmptyPlaceholder = false,217 hideEmptyPlaceholder = false,
221 topFadeEnabled = false,218 topFadeEnabled = false,
222}: MessagesTimelineProps) {219}: MessagesTimelineProps) {
223 const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());220 const [expandedRunIds, setExpandedRunIds] = useState<ReadonlySet<RunId>>(new Set());
224 const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());221
225 const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>());222 // Toggling a fold inserts/removes rows between the fold row and the final
226 223 // message — everything above the trigger is unchanged, so the trigger stays
227 const onToggleTurnFold = useCallback((turnId: TurnId) => {224 // put as long as the list doesn't re-anchor. maintainScrollAtEnd would do
228 setExpandedTurnIds((existing) => {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) => {
229 const next = new Set(existing);232 const next = new Set(existing);
230 if (next.has(turnId)) {233 if (next.has(runId)) {
231 next.delete(turnId);234 next.delete(runId);
232 } else {235 } else {
233 next.add(turnId);236 next.add(runId);
234 }237 }
235 return next;238 return next;
236 });239 });
@@ -271,41 +274,39 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 34 unmodified lines
271 274
272 // An in-session interrupt leaves its turn expanded so the user keeps their275 // An in-session interrupt leaves its turn expanded so the user keeps their
273 // place; the next turn (or a reload, since this is local state) folds it.276 // place; the next turn (or a reload, since this is local state) folds it.
274 const previousLatestTurnRef = useRef(latestTurn);277 const previousLatestRunRef = useRef(latestRun);
275 useEffect(() => {278 useEffect(() => {
276 const previous = previousLatestTurnRef.current;279 const previous = previousLatestRunRef.current;
277 previousLatestTurnRef.current = latestTurn;280 previousLatestRunRef.current = latestRun;
278 if (!latestTurn || previous?.turnId === undefined) {281 if (!latestRun || previous?.runId === undefined) {
279 return;282 return;
280 }283 }
281 if (latestTurn.turnId === previous.turnId) {284 if (latestRun.runId === previous.runId) {
282 if (previous.state === "running" && latestTurn.state === "interrupted") {285 if (previous.status === "running" && latestRun.status === "interrupted") {
283 setExpandedTurnIds((existing) => {286 setExpandedRunIds((existing) => {
284 const next = new Set(existing);287 const next = new Set(existing);
285 next.add(latestTurn.turnId);288 next.add(latestRun.runId);
286 return next;289 return next;
287 });290 });
288 }291 }
289 return;292 return;
290 }293 }
291 setExpandedTurnIds((existing) => {294 setExpandedRunIds((existing) => {
292 if (!existing.has(previous.turnId)) {295 if (!existing.has(previous.runId)) {
293 return existing;296 return existing;
294 }297 }
295 const next = new Set(existing);298 const next = new Set(existing);
296 next.delete(previous.turnId);299 next.delete(previous.runId);
297 return next;300 return next;
298 });301 });
299 }, [latestTurn]);302 }, [latestRun]);
300 303
301 const rawRows = useMemo(304 const rawRows = useMemo(
302 () =>305 () =>
303 deriveMessagesTimelineRows({306 deriveMessagesTimelineRows({
304 timelineEntries,307 timelineEntries,
305 latestTurn,308 latestRun,
306 runningTurnId,309 expandedRunIds,
307 expandedTurnIds,
308 expandedWorkGroupIds,
309 isWorking,310 isWorking,
310 activeTurnStartedAt,311 activeTurnStartedAt,
311 turnDiffSummaryByAssistantMessageId,312 turnDiffSummaryByAssistantMessageId,
@@ -313,10 +314,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 1 unmodified line
313 }),314 }),
314 [315 [
315 timelineEntries,316 timelineEntries,
316 latestTurn,317 latestRun,
317 runningTurnId,318 expandedRunIds,
318 expandedTurnIds,
319 expandedWorkGroupIds,
320 isWorking,319 isWorking,
321 activeTurnStartedAt,320 activeTurnStartedAt,
322 turnDiffSummaryByAssistantMessageId,321 turnDiffSummaryByAssistantMessageId,
@@ -843,7 +842,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time · 520 unmodified lines
843 // they sit closer to the work that follows them.842 // they sit closer to the work that follows them.
844 (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) ||843 (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) ||
845 row.kind === "work" ||844 row.kind === "work" ||
846 row.kind === "work-toggle"845 row.kind === "event"
847 ? "pb-2"846 ? "pb-2"
848 : "pb-4",847 : "pb-4",
849 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,848 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
861 <AssistantTimelineRow row={row} />860 <AssistantTimelineRow row={row} />
862 ) : null}861 ) : null}
863 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}862 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}
863 {row.kind === "event" ? <V2EventTimelineRow row={row} /> : null}
864 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}864 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
865 </div>865 </div>
866 );866 );
@@ -1005,7 +1005,7 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn- · 138 unmodified lines
1005 type="button"1005 type="button"
1006 aria-expanded={row.expanded}1006 aria-expanded={row.expanded}
1007 data-scroll-anchor-ignore1007 data-scroll-anchor-ignore
1008 onClick={() => ctx.onToggleTurnFold(row.turnId)}1008 onClick={() => ctx.onToggleTurnFold(row.runId)}
1009 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"1009 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"
1010 >1010 >
1011 <span>{row.label}</span>1011 <span>{row.label}</span>
@@ -1091,6 +1091,173 @@ function ProposedPlanTimelineRow({ · 79 unmodified lines
1091 );1091 );
1092}1092}
1093 1093
1094type V2EventTone = "muted" | "warning" | "danger" | "success";
1095
1096function 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
1186function 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
1094function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {1261function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
1095 return (1262 return (
1096 <div className="py-0.5 pl-1.5">1263 <div className="py-0.5 pl-1.5">
@@ -1244,7 +1411,7 @@ const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection( · 147 unmodified lines
1244 turnSummary: TurnDiffSummary | undefined;1411 turnSummary: TurnDiffSummary | undefined;
1245 routeThreadKey: string;1412 routeThreadKey: string;
1246 resolvedTheme: "light" | "dark";1413 resolvedTheme: "light" | "dark";
1247 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;1414 onOpenTurnDiff: (runId: RunId, filePath?: string) => void;
1248}) {1415}) {
1249 if (!turnSummary) return null;1416 if (!turnSummary) return null;
1250 const checkpointFiles = turnSummary.files;1417 const checkpointFiles = turnSummary.files;
@@ -1274,12 +1441,10 @@ function AssistantChangedFilesSectionInner({ · 23 unmodified lines
1274 checkpointFiles: TurnDiffSummary["files"];1441 checkpointFiles: TurnDiffSummary["files"];
1275 routeThreadKey: string;1442 routeThreadKey: string;
1276 resolvedTheme: "light" | "dark";1443 resolvedTheme: "light" | "dark";
1277 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;1444 onOpenTurnDiff: (runId: RunId, filePath?: string) => void;
1278}) {1445}) {
1279 const activity = use(TimelineRowActivityCtx);1446 const allDirectoriesExpanded = useUiStateStore(
1280 const isLatestTurn = activity.latestTurnId === turnSummary.turnId;1447 (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.runId] ?? true,
1281 const persistedExpanded = useUiStateStore(
1282 (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId],
1283 );1448 );
1284 const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded);1449 const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded);
1285 const [autoExpanded] = useState(() =>1450 const [autoExpanded] = useState(() =>
@@ -1289,19 +1454,46 @@ function AssistantChangedFilesSectionInner({ · 3 unmodified lines
1289 const expanded = persistedExpanded ?? (isLatestTurn && autoExpanded);1454 const expanded = persistedExpanded ?? (isLatestTurn && autoExpanded);
1290 1455
1291 return (1456 return (
1292 <ChangedFilesCard1457 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
1293 turnId={turnSummary.turnId}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-['']">
1294 files={checkpointFiles}1459 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
1295 expanded={expanded}1460 <span>Changed files ({changedFileCountLabel})</span>
1296 showCompactPreview={isLatestTurn}1461 {hasNonZeroStat(summaryStat) && (
1297 allDirectoriesExpanded={allDirectoriesExpanded}1462 <>
1298 resolvedTheme={resolvedTheme}1463 <span className="mx-1">•</span>
1299 onExpandedChange={(nextExpanded) =>1464 <DiffStatLabel additions={summaryStat.additions} deletions={summaryStat.deletions} />
1300 setExpanded(routeThreadKey, turnSummary.turnId, nextExpanded)1465 </>
1301 }1466 )}
1302 onToggleAllDirectories={() => setAllDirectoriesExpanded((current) => !current)}1467 </p>
1303 onOpenTurnDiff={onOpenTurnDiff}1468 <div className="flex items-center gap-1.5">
1304 />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>
1305 );1497 );
1306}1498}
1307 1499
@@ -1850,8 +2042,8 @@ function buildToolCallExpandedBody( · 542 unmodified lines
1850 workspaceRoot: string | undefined,2042 workspaceRoot: string | undefined,
1851): string | null {2043): string | null {
1852 const blocks: string[] = [];2044 const blocks: string[] = [];
1853 if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) {2045 if (workEntry.itemType === "dynamic_tool" && workEntry.toolData !== undefined) {
1854 blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`);2046 blocks.push(`Tool call\n${JSON.stringify(workEntry.toolData, null, 2)}`);
1855 }2047 }
1856 const raw = workEntryRawCommand(workEntry);2048 const raw = workEntryRawCommand(workEntry);
1857 if (raw?.trim()) {2049 if (raw?.trim()) {
@@ -1870,14 +2062,25 @@ function buildToolCallExpandedBody( · 12 unmodified lines
1870 .join("\n"),2062 .join("\n"),
1871 );2063 );
1872 }2064 }
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 }
1873 return blocks.length > 0 ? blocks.join("\n\n") : null;2079 return blocks.length > 0 ? blocks.join("\n\n") : null;
1874}2080}
1875 2081
1876function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {2082function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {
1877 if (2083 if (workEntry.itemType === "user_input_request") {
1878 workEntry.sourceActivityKind === "user-input.requested" ||
1879 workEntry.sourceActivityKind === "user-input.resolved"
1880 ) {
1881 return "message-circle";2084 return "message-circle";
1882 }2085 }
1883 if (workEntry.requestKind === "command") return "terminal";2086 if (workEntry.requestKind === "command") return "terminal";
@@ -1891,13 +2094,12 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { · 7 unmodified lines
1891 return "square-pen";2094 return "square-pen";
1892 }2095 }
1893 if (workEntry.itemType === "web_search") return "globe";2096 if (workEntry.itemType === "web_search") return "globe";
1894 if (workEntry.itemType === "image_view") return "eye";2097 if (workEntry.itemType === "file_search") return "eye";
1895 2098
1896 switch (workEntry.itemType) {2099 switch (workEntry.itemType) {
1897 case "mcp_tool_call":2100 case "dynamic_tool":
1898 return "wrench";2101 return "wrench";
1899 case "dynamic_tool_call":2102 case "subagent":
1900 case "collab_agent_tool_call":
1901 return "hammer";2103 return "hammer";
1902 }2104 }
1903 2105
@@ -1929,7 +2131,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 25 unmodified lines
1929 const activity = use(TimelineRowActivityCtx);2131 const activity = use(TimelineRowActivityCtx);
1930 const [expanded, setExpanded] = useState(false);2132 const [expanded, setExpanded] = useState(false);
1931 const iconConfig = workToneIcon(workEntry.tone);2133 const iconConfig = workToneIcon(workEntry.tone);
1932 const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning";2134 const showWarningIndicator = false;
1933 const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry);2135 const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry);
1934 const heading = toolWorkEntryHeading(workEntry);2136 const heading = toolWorkEntryHeading(workEntry);
1935 const rawPreview = workEntryPreview(workEntry, workspaceRoot);2137 const rawPreview = workEntryPreview(workEntry, workspaceRoot);
@@ -1943,9 +2145,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 7 unmodified lines
1943 const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot);2145 const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot);
1944 const canExpand = expandedBody !== null;2146 const canExpand = expandedBody !== null;
1945 const showFailedIndicator = workEntryIndicatesToolFailure(workEntry);2147 const showFailedIndicator = workEntryIndicatesToolFailure(workEntry);
1946 const showDestructiveRowStyle =2148 const showDestructiveRowStyle = showFailedIndicator && !workLogEntryIsToolLike(workEntry);
1947 showFailedIndicator &&
1948 (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry));
1949 const iconWrapperClass = cn(2149 const iconWrapperClass = cn(
1950 "flex size-5 shrink-0 items-center justify-center",2150 "flex size-5 shrink-0 items-center justify-center",
1951 showWarningIndicator2151 showWarningIndicator
@@ -1988,6 +2188,8 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 36 unmodified lines
1988 canExpand &&2188 canExpand &&
1989 "cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70",2189 "cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70",
1990 )}2190 )}
2191 data-v2-item-type={workEntry.projectedItem?.item.type}
2192 data-v2-item-visibility={workEntry.projectedItem?.visibility}
1991 {...rowToggleProps}2193 {...rowToggleProps}
1992 >2194 >
1993 <div className="flex select-none items-center gap-1.5 transition-[opacity,translate] duration-200">2195 <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
2001 <div className="min-w-0 flex-1 overflow-hidden">2203 <div className="min-w-0 flex-1 overflow-hidden">
2002 <p className="flex min-w-0 w-full items-baseline gap-1.5 text-[12px] leading-5">2204 <p className="flex min-w-0 w-full items-baseline gap-1.5 text-[12px] leading-5">
2003 <span className={cn("min-w-0 shrink truncate", headingClass)}>{heading}</span>2205 <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}
2004 {preview && (2212 {preview && (
2005 <span className="min-w-0 flex-1 truncate text-muted-foreground/55">{preview}</span>2213 <span className="min-w-0 flex-1 truncate text-muted-foreground/55">{preview}</span>
2006 )}2214 )}
apps/web/src/components/settings/SettingsPanels.tsx+2−4
@@ -24,6 +24,7 @@ import { · 23 unmodified lines
24 type SidebarProjectGroupingMode,24 type SidebarProjectGroupingMode,
25} from "@t3tools/contracts";25} from "@t3tools/contracts";
26import { scopeThreadRef } from "@t3tools/client-runtime/environment";26import { scopeThreadRef } from "@t3tools/client-runtime/environment";
27import { presentThreadShell } from "@t3tools/client-runtime/state/shell";
27import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";28import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
28import {29import {
29 isAtomCommandInterrupted,30 isAtomCommandInterrupted,
@@ -2245,10 +2246,7 @@ export function ArchivedThreadsPanel() { · 2215 unmodified lines
2245 ),2246 ),
2246 );2247 );
2247 const threads = archivedSnapshots.flatMap(({ environmentId, snapshot }) =>2248 const threads = archivedSnapshots.flatMap(({ environmentId, snapshot }) =>
2248 snapshot.threads.map((thread) => ({2249 snapshot.threads.map((thread) => presentThreadShell(environmentId, thread)),
2249 ...thread,
2250 environmentId,
2251 })),
2252 );2250 );
2253 2251
2254 const archivedProjects = Array.from(projectsByEnvironmentAndId.values());2252 const archivedProjects = Array.from(projectsByEnvironmentAndId.values());
apps/web/src/connection/storage.ts+36−51
@@ -6,6 +6,10 @@ import { · 5 unmodified lines
6 ConnectionTargetStore,6 ConnectionTargetStore,
7 EMPTY_CONNECTION_CATALOG_DOCUMENT,7 EMPTY_CONNECTION_CATALOG_DOCUMENT,
8 EnvironmentCacheStore,8 EnvironmentCacheStore,
9 ORCHESTRATION_CACHE_SCHEMA_VERSION,
10 StoredOrchestrationShellSnapshot,
11 StoredOrchestrationThreadSnapshot,
12 decodeOrDiscardOrchestrationCache,
9 registerConnectionInCatalog,13 registerConnectionInCatalog,
10 removeCatalogValue,14 removeCatalogValue,
11 removeConnectionFromCatalog,15 removeConnectionFromCatalog,
@@ -17,14 +21,7 @@ import { · 5 unmodified lines
17 CredentialStore,21 CredentialStore,
18 ProfileStore,22 ProfileStore,
19} from "@t3tools/client-runtime/connection";23} from "@t3tools/client-runtime/connection";
20import {24import { EnvironmentId, ThreadId } from "@t3tools/contracts";
21 EnvironmentId,
22 OrchestrationShellSnapshot,
23 OrchestrationThreadDetailSnapshot,
24 ServerConfig,
25 ThreadId,
26 VcsListRefsResult,
27} from "@t3tools/contracts";
28import * as Context from "effect/Context";25import * as Context from "effect/Context";
29import * as Effect from "effect/Effect";26import * as Effect from "effect/Effect";
30import * as Layer from "effect/Layer";27import * as Layer from "effect/Layer";
@@ -41,23 +38,9 @@ const THREAD_STORE_NAME = "thread"; · 10 unmodified lines
41const SERVER_CONFIG_STORE_NAME = "server-config";38const SERVER_CONFIG_STORE_NAME = "server-config";
42const VCS_REFS_STORE_NAME = "vcs-refs";39const VCS_REFS_STORE_NAME = "vcs-refs";
43const CATALOG_KEY = "document";40const CATALOG_KEY = "document";
44const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;41const StoredShellSnapshot = StoredOrchestrationShellSnapshot;
45
46const StoredShellSnapshot = Schema.Struct({
47 schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION),
48 environmentId: EnvironmentId,
49 snapshot: OrchestrationShellSnapshot,
50});
51const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot);42const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot);
52// v2 stores the snapshot sequence alongside the thread so a warm cache can43const StoredThreadSnapshot = StoredOrchestrationThreadSnapshot;
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.
55const StoredThreadSnapshot = Schema.Struct({
56 schemaVersion: Schema.Literal(2),
57 environmentId: EnvironmentId,
58 threadId: ThreadId,
59 snapshot: OrchestrationThreadDetailSnapshot,
60});
61const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot);44const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot);
62const StoredServerConfig = Schema.Struct({45const StoredServerConfig = Schema.Struct({
63 schemaVersion: Schema.Literal(1),46 schemaVersion: Schema.Literal(1),
@@ -469,25 +452,24 @@ export const connectionStorageLayer = Layer.effectContext( · 405 unmodified lines
469 if (typeof raw !== "string") {452 if (typeof raw !== "string") {
470 return Effect.succeed(Option.none());453 return Effect.succeed(Option.none());
471 }454 }
472 return decodeStoredShellSnapshot(raw).pipe(455 return decodeOrDiscardOrchestrationCache(
473 Effect.mapError((cause) => persistenceError("load-shell", cause)),456 decodeStoredShellSnapshot(raw).pipe(
474 Effect.map((stored) =>457 Effect.mapError((cause) => persistenceError("load-shell", cause)),
475 stored.environmentId === environmentId458 Effect.map((stored) =>
476 ? Option.some(stored.snapshot)459 stored.environmentId === environmentId
477 : Option.none(),460 ? Option.some(stored.snapshot)
461 : Option.none(),
462 ),
478 ),463 ),
464 removeDatabaseValue(database, SHELL_STORE_NAME, environmentId),
479 );465 );
480 }),466 }),
481 Effect.mapError((cause) =>467 Effect.mapError((cause) => persistenceError("load-shell", cause)),
482 cause._tag === "ConnectionPersistenceError"
483 ? cause
484 : persistenceError("load-shell", cause),
485 ),
486 ),468 ),
487 saveShell: (environmentId, snapshot) =>469 saveShell: (environmentId, snapshot) =>
488 Effect.gen(function* () {470 Effect.gen(function* () {
489 const encoded = yield* encodeStoredShellSnapshot({471 const encoded = yield* encodeStoredShellSnapshot({
490 schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION,472 schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
491 environmentId,473 environmentId,
492 snapshot,474 snapshot,
493 }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause)));475 }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause)));
@@ -543,33 +525,36 @@ export const connectionStorageLayer = Layer.effectContext( · 49 unmodified lines
543 if (typeof raw !== "string") {525 if (typeof raw !== "string") {
544 return Effect.succeed(Option.none());526 return Effect.succeed(Option.none());
545 }527 }
546 return decodeStoredThreadSnapshot(raw).pipe(528 return decodeOrDiscardOrchestrationCache(
547 Effect.mapError((cause) => persistenceError("load-thread", cause)),529 decodeStoredThreadSnapshot(raw).pipe(
548 Effect.map((stored) =>530 Effect.mapError((cause) => persistenceError("load-thread", cause)),
549 stored.environmentId === environmentId && stored.threadId === threadId531 Effect.map((stored) =>
550 ? Option.some(stored.snapshot)532 stored.environmentId === environmentId && stored.threadId === threadId
551 : Option.none(),533 ? Option.some(stored.thread)
534 : Option.none(),
535 ),
536 ),
537 removeDatabaseValue(
538 database,
539 THREAD_STORE_NAME,
540 threadCacheKey(environmentId, threadId),
552 ),541 ),
553 );542 );
554 }),543 }),
555 Effect.mapError((cause) =>544 Effect.mapError((cause) => persistenceError("load-thread", cause)),
556 cause._tag === "ConnectionPersistenceError"
557 ? cause
558 : persistenceError("load-thread", cause),
559 ),
560 ),545 ),
561 saveThread: (environmentId, snapshot) =>546 saveThread: (environmentId, snapshot) =>
562 Effect.gen(function* () {547 Effect.gen(function* () {
563 const encoded = yield* encodeStoredThreadSnapshot({548 const encoded = yield* encodeStoredThreadSnapshot({
564 schemaVersion: 2,549 schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
565 environmentId,550 environmentId,
566 threadId: snapshot.thread.id,551 threadId: thread.thread.id,
567 snapshot,552 thread,
568 }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause)));553 }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause)));
569 yield* writeDatabaseValue(554 yield* writeDatabaseValue(
570 database,555 database,
571 THREAD_STORE_NAME,556 THREAD_STORE_NAME,
572 threadCacheKey(environmentId, snapshot.thread.id),557 threadCacheKey(environmentId, thread.thread.id),
573 encoded,558 encoded,
574 );559 );
575 }).pipe(560 }).pipe(
apps/web/src/diffPanelStore.test.ts+5−5
@@ -1,5 +1,5 @@
1import { scopeThreadRef } from "@t3tools/client-runtime/environment";1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts";2import { EnvironmentId, ThreadId, RunId } from "@t3tools/contracts";
3import { beforeEach, describe, expect, it } from "vite-plus/test";3import { beforeEach, describe, expect, it } from "vite-plus/test";
4 4
5import { selectThreadDiffPanelSelection, useDiffPanelStore } from "./diffPanelStore";5import { selectThreadDiffPanelSelection, useDiffPanelStore } from "./diffPanelStore";
@@ -31,7 +31,7 @@ describe("diffPanelStore", () => { · 25 unmodified lines
31 31
32 it("clears incompatible selection fields when changing scopes", () => {32 it("clears incompatible selection fields when changing scopes", () => {
33 const store = useDiffPanelStore.getState();33 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");
35 store.selectGitScope(THREAD_REF, "unstaged");35 store.selectGitScope(THREAD_REF, "unstaged");
36 36
37 expect(37 expect(
@@ -45,7 +45,7 @@ describe("diffPanelStore", () => { · 7 unmodified lines
45 });45 });
46 46
47 it("increments the reveal request when opening the same turn file again", () => {47 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");
49 useDiffPanelStore.getState().selectTurn(THREAD_REF, turnId, "src/app.ts");49 useDiffPanelStore.getState().selectTurn(THREAD_REF, turnId, "src/app.ts");
50 useDiffPanelStore.getState().selectTurn(THREAD_REF, turnId, "src/app.ts");50 useDiffPanelStore.getState().selectTurn(THREAD_REF, turnId, "src/app.ts");
51 51
@@ -65,8 +65,8 @@ describe("diffPanelStore", () => { · 13 unmodified lines
65 });65 });
66 66
67 it("reconciles a missing turn selection to the latest available turn", () => {67 it("reconciles a missing turn selection to the latest available turn", () => {
68 const missingTurnId = TurnId.make("turn-missing");68 const missingTurnId = RunId.make("turn-missing");
69 const latestTurnId = TurnId.make("turn-latest");69 const latestTurnId = RunId.make("turn-latest");
70 useDiffPanelStore.getState().selectTurn(THREAD_REF, missingTurnId, "src/app.ts");70 useDiffPanelStore.getState().selectTurn(THREAD_REF, missingTurnId, "src/app.ts");
71 useDiffPanelStore.getState().reconcileTurnSelection(THREAD_REF, [latestTurnId]);71 useDiffPanelStore.getState().reconcileTurnSelection(THREAD_REF, [latestTurnId]);
72 72
apps/web/src/diffPanelStore.ts+5−5
@@ -1,5 +1,5 @@
1import { scopedThreadKey } from "@t3tools/client-runtime/environment";1import { scopedThreadKey } from "@t3tools/client-runtime/environment";
2import type { ScopedThreadRef, TurnId } from "@t3tools/contracts";2import type { RunId, ScopedThreadRef } from "@t3tools/contracts";
3import { create } from "zustand";3import { create } from "zustand";
4import { createJSONStorage, persist } from "zustand/middleware";4import { createJSONStorage, persist } from "zustand/middleware";
5 5
@@ -8,7 +8,7 @@ import { resolveStorage } from "./lib/storage"; · 2 unmodified lines
8export type DiffPanelSelection =8export type DiffPanelSelection =
9 | { kind: "branch"; baseRef: string | null }9 | { kind: "branch"; baseRef: string | null }
10 | { kind: "unstaged" }10 | { kind: "unstaged" }
11 | { kind: "turn"; turnId: TurnId; filePath: string | null; revealRequestId: number };11 | { kind: "turn"; turnId: RunId; filePath: string | null; revealRequestId: number };
12 12
13const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null };13const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null };
14const DEFAULT_WORKING_TREE_SELECTION: DiffPanelSelection = { kind: "unstaged" };14const DEFAULT_WORKING_TREE_SELECTION: DiffPanelSelection = { kind: "unstaged" };
@@ -18,8 +18,8 @@ interface DiffPanelStoreState { · 3 unmodified lines
18 branchBaseRefByThreadKey: Record<string, string | null>;18 branchBaseRefByThreadKey: Record<string, string | null>;
19 selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void;19 selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void;
20 selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void;20 selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void;
21 selectTurn: (ref: ScopedThreadRef, turnId: TurnId, filePath?: string) => void;21 selectTurn: (ref: ScopedThreadRef, turnId: RunId, filePath?: string) => void;
22 reconcileTurnSelection: (ref: ScopedThreadRef, availableTurnIds: ReadonlyArray<TurnId>) => void;22 reconcileTurnSelection: (ref: ScopedThreadRef, availableTurnIds: ReadonlyArray<RunId>) => void;
23 removeThread: (ref: ScopedThreadRef) => void;23 removeThread: (ref: ScopedThreadRef) => void;
24}24}
25 25
@@ -119,7 +119,7 @@ export const useDiffPanelStore = create<DiffPanelStoreState>()( · 93 unmodified lines
119 }),119 }),
120 {120 {
121 name: "t3code:diff-panel-state:v1",121 name: "t3code:diff-panel-state:v1",
122 version: 1,122 version: 2,
123 storage: createJSONStorage(() =>123 storage: createJSONStorage(() =>
124 resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined),124 resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined),
125 ),125 ),
apps/web/src/historyBootstrap.test.ts+7−7
@@ -14,7 +14,7 @@ describe("buildBootstrapInput", () => { · 13 unmodified lines
14 role: "user",14 role: "user",
15 text: "hello",15 text: "hello",
16 createdAt: "2026-02-09T00:00:00.000Z",16 createdAt: "2026-02-09T00:00:00.000Z",
17 turnId: null,17 runId: null,
18 updatedAt: "2026-02-09T00:00:00.000Z",18 updatedAt: "2026-02-09T00:00:00.000Z",
19 streaming: false,19 streaming: false,
20 },20 },
@@ -23,7 +23,7 @@ describe("buildBootstrapInput", () => { · 2 unmodified lines
23 role: "assistant",23 role: "assistant",
24 text: "world",24 text: "world",
25 createdAt: "2026-02-09T00:00:01.000Z",25 createdAt: "2026-02-09T00:00:01.000Z",
26 turnId: null,26 runId: null,
27 updatedAt: "2026-02-09T00:00:01.000Z",27 updatedAt: "2026-02-09T00:00:01.000Z",
28 streaming: false,28 streaming: false,
29 },29 },
@@ -49,7 +49,7 @@ describe("buildBootstrapInput", () => { · 19 unmodified lines
49 role: "user",49 role: "user",
50 text: "first question with details",50 text: "first question with details",
51 createdAt: "2026-02-09T00:00:00.000Z",51 createdAt: "2026-02-09T00:00:00.000Z",
52 turnId: null,52 runId: null,
53 updatedAt: "2026-02-09T00:00:00.000Z",53 updatedAt: "2026-02-09T00:00:00.000Z",
54 streaming: false,54 streaming: false,
55 },55 },
@@ -58,7 +58,7 @@ describe("buildBootstrapInput", () => { · 2 unmodified lines
58 role: "assistant",58 role: "assistant",
59 text: "first answer with details",59 text: "first answer with details",
60 createdAt: "2026-02-09T00:00:01.000Z",60 createdAt: "2026-02-09T00:00:01.000Z",
61 turnId: null,61 runId: null,
62 updatedAt: "2026-02-09T00:00:01.000Z",62 updatedAt: "2026-02-09T00:00:01.000Z",
63 streaming: false,63 streaming: false,
64 },64 },
@@ -67,7 +67,7 @@ describe("buildBootstrapInput", () => { · 2 unmodified lines
67 role: "user",67 role: "user",
68 text: "second question with details",68 text: "second question with details",
69 createdAt: "2026-02-09T00:00:02.000Z",69 createdAt: "2026-02-09T00:00:02.000Z",
70 turnId: null,70 runId: null,
71 updatedAt: "2026-02-09T00:00:02.000Z",71 updatedAt: "2026-02-09T00:00:02.000Z",
72 streaming: false,72 streaming: false,
73 },73 },
@@ -92,7 +92,7 @@ describe("buildBootstrapInput", () => { · 18 unmodified lines
92 role: "user",92 role: "user",
93 text: "old context",93 text: "old context",
94 createdAt: "2026-02-09T00:00:00.000Z",94 createdAt: "2026-02-09T00:00:00.000Z",
95 turnId: null,95 runId: null,
96 updatedAt: "2026-02-09T00:00:00.000Z",96 updatedAt: "2026-02-09T00:00:00.000Z",
97 streaming: false,97 streaming: false,
98 },98 },
@@ -124,7 +124,7 @@ describe("buildBootstrapInput", () => { · 25 unmodified lines
124 },124 },
125 ],125 ],
126 createdAt: "2026-02-09T00:00:00.000Z",126 createdAt: "2026-02-09T00:00:00.000Z",
127 turnId: null,127 runId: null,
128 updatedAt: "2026-02-09T00:00:00.000Z",128 updatedAt: "2026-02-09T00:00:00.000Z",
129 streaming: false,129 streaming: false,
130 },130 },
apps/web/src/hooks/useThreadActions.ts+2−2
@@ -160,7 +160,7 @@ export function useThreadActions() { · 159 unmodified lines
160 const resolved = resolveThreadTarget(target);160 const resolved = resolveThreadTarget(target);
161 if (!resolved) return AsyncResult.success(undefined);161 if (!resolved) return AsyncResult.success(undefined);
162 const { thread, threadRef } = resolved;162 const { thread, threadRef } = resolved;
163 if (thread.session?.status === "running" && thread.session.activeTurnId != null) {163 if (thread.runtime?.status === "running" && thread.runtime.activeRunId != null) {
164 return AsyncResult.failure(164 return AsyncResult.failure(
165 Cause.fail(165 Cause.fail(
166 new ThreadArchiveBlockedError({166 new ThreadArchiveBlockedError({
@@ -277,7 +277,7 @@ export function useThreadActions() { · 110 unmodified lines
277 shouldDeleteWorktree = confirmationResult.value;277 shouldDeleteWorktree = confirmationResult.value;
278 }278 }
279 279
280 if (thread.session && thread.session.status !== "stopped") {280 if (thread.runtime !== null) {
281 await stopThreadSession({281 await stopThreadSession({
282 environmentId: threadRef.environmentId,282 environmentId: threadRef.environmentId,
283 input: { threadId: threadRef.threadId },283 input: { threadId: threadRef.threadId },
apps/web/src/hooks/useTurnDiffSummaries.ts+4−4
@@ -1,5 +1,5 @@
1import { useMemo } from "react";1import { useMemo } from "react";
2import { inferCheckpointTurnCountByTurnId } from "../session-logic";2import { inferCheckpointTurnCountByRunId } from "../session-logic";
3import type { Thread, TurnDiffSummary } from "../types";3import type { Thread, TurnDiffSummary } from "../types";
4 4
5export function useTurnDiffSummaries(activeThread: Thread | null | undefined) {5export function useTurnDiffSummaries(activeThread: Thread | null | undefined) {
@@ -10,10 +10,10 @@ export function useTurnDiffSummaries(activeThread: Thread | null | undefined) { · 4 unmodified lines
10 return activeThread.checkpoints;10 return activeThread.checkpoints;
11 }, [activeThread]);11 }, [activeThread]);
12 12
13 const inferredCheckpointTurnCountByTurnId = useMemo(13 const inferredCheckpointTurnCountByRunId = useMemo(
14 () => inferCheckpointTurnCountByTurnId(turnDiffSummaries),14 () => inferCheckpointTurnCountByRunId(turnDiffSummaries),
15 [turnDiffSummaries],15 [turnDiffSummaries],
16 );16 );
17 17
18 return { turnDiffSummaries, inferredCheckpointTurnCountByTurnId };18 return { turnDiffSummaries, inferredCheckpointTurnCountByRunId };
19}19}
apps/web/src/lib/contextWindow.test.ts+35−75
@@ -1,84 +1,44 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import { EventId, type OrchestrationThreadActivity, TurnId } from "@t3tools/contracts";
3
4import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "./contextWindow";2import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "./contextWindow";
5 3
6function makeActivity(id: string, kind: string, payload: unknown): OrchestrationThreadActivity {4describe("V2 context window presentation", () => {
7 return {5 it("uses retained compaction token data when available", () => {
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
18describe("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", () => {
48 const snapshot = deriveLatestContextWindowSnapshot([6 const snapshot = deriveLatestContextWindowSnapshot([
49 makeActivity("activity-1", "context-window.updated", {7 {
50 usedTokens: 0,8 id: "compaction-1",
51 maxTokens: 100_000,9 createdAt: "2026-06-20T00:00:00.000Z",
52 }),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 },
53 ]);36 ]);
54 37 expect(snapshot?.usedTokens).toBe(2_000);
55 expect(snapshot).toMatchObject({38 expect(snapshot?.totalProcessedTokens).toBe(10_000);
56 usedTokens: 0,
57 maxTokens: 100_000,
58 remainingTokens: 100_000,
59 usedPercentage: 0,
60 remainingPercentage: 100,
61 });
62 });39 });
63 40
64 it("formats compact token counts", () => {41 it("formats compact token values", () => {
65 expect(formatContextWindowTokens(999)).toBe("999");42 expect(formatContextWindowTokens(1_500)).toBe("1.5k");
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);
83 });43 });
84});44});
apps/web/src/lib/contextWindow.ts+23−31
@@ -1,17 +1,10 @@
1import type { OrchestrationThreadActivity, ThreadTokenUsageSnapshot } from "@t3tools/contracts";1import type { ThreadTokenUsageSnapshot } from "@t3tools/contracts";
2 2import type { ThreadWorkEntry } from "@t3tools/client-runtime/state/shell";
3function asRecord(value: unknown): Record<string, unknown> | null {
4 return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
5}
6 3
7function asFiniteNumber(value: unknown): number | null {4function asFiniteNumber(value: unknown): number | null {
8 return typeof value === "number" && Number.isFinite(value) ? value : null;5 return typeof value === "number" && Number.isFinite(value) ? value : null;
9}6}
10 7
11function asBoolean(value: unknown): boolean | null {
12 return typeof value === "boolean" ? value : null;
13}
14
15type NullableContextWindowUsage = {8type NullableContextWindowUsage = {
16 readonly [Key in keyof ThreadTokenUsageSnapshot]: undefined extends ThreadTokenUsageSnapshot[Key]9 readonly [Key in keyof ThreadTokenUsageSnapshot]: undefined extends ThreadTokenUsageSnapshot[Key]
17 ? Exclude<ThreadTokenUsageSnapshot[Key], undefined> | null10 ? Exclude<ThreadTokenUsageSnapshot[Key], undefined> | null
@@ -48,21 +41,20 @@ export function formatProviderDisplayName(provider: string | null | undefined): · 30 unmodified lines
48}41}
49 42
50export function deriveLatestContextWindowSnapshot(43export function deriveLatestContextWindowSnapshot(
51 activities: ReadonlyArray<OrchestrationThreadActivity>,44 entries: ReadonlyArray<ThreadWorkEntry>,
52): ContextWindowSnapshot | null {45): ContextWindowSnapshot | null {
53 for (let index = activities.length - 1; index >= 0; index -= 1) {46 for (let index = entries.length - 1; index >= 0; index -= 1) {
54 const activity = activities[index];47 const entry = entries[index];
55 if (!activity || activity.kind !== "context-window.updated") {48 if (!entry || entry.structuredPayload.type !== "compaction") {
56 continue;49 continue;
57 }50 }
58 51 const payload = entry.structuredPayload;
59 const payload = asRecord(activity.payload);52 const usedTokens = asFiniteNumber(payload.afterTokenCount);
60 const usedTokens = asFiniteNumber(payload?.usedTokens);
61 if (usedTokens === null || usedTokens < 0) {53 if (usedTokens === null || usedTokens < 0) {
62 continue;54 continue;
63 }55 }
64 56
65 const maxTokens = asFiniteNumber(payload?.maxTokens);57 const maxTokens = null;
66 const usedPercentage =58 const usedPercentage =
67 maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null;59 maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null;
68 const remainingTokens =60 const remainingTokens =
@@ -71,24 +63,24 @@ export function deriveLatestContextWindowSnapshot( · 2 unmodified lines
71 63
72 return {64 return {
73 usedTokens,65 usedTokens,
74 totalProcessedTokens: asFiniteNumber(payload?.totalProcessedTokens),66 totalProcessedTokens: asFiniteNumber(payload.beforeTokenCount),
75 maxTokens,67 maxTokens,
76 remainingTokens,68 remainingTokens,
77 usedPercentage,69 usedPercentage,
78 remainingPercentage,70 remainingPercentage,
79 inputTokens: asFiniteNumber(payload?.inputTokens),71 inputTokens: null,
80 cachedInputTokens: asFiniteNumber(payload?.cachedInputTokens),72 cachedInputTokens: null,
81 outputTokens: asFiniteNumber(payload?.outputTokens),73 outputTokens: null,
82 reasoningOutputTokens: asFiniteNumber(payload?.reasoningOutputTokens),74 reasoningOutputTokens: null,
83 lastUsedTokens: asFiniteNumber(payload?.lastUsedTokens),75 lastUsedTokens: null,
84 lastInputTokens: asFiniteNumber(payload?.lastInputTokens),76 lastInputTokens: null,
85 lastCachedInputTokens: asFiniteNumber(payload?.lastCachedInputTokens),77 lastCachedInputTokens: null,
86 lastOutputTokens: asFiniteNumber(payload?.lastOutputTokens),78 lastOutputTokens: null,
87 lastReasoningOutputTokens: asFiniteNumber(payload?.lastReasoningOutputTokens),79 lastReasoningOutputTokens: null,
88 toolUses: asFiniteNumber(payload?.toolUses),80 toolUses: null,
89 durationMs: asFiniteNumber(payload?.durationMs),81 durationMs: null,
90 compactsAutomatically: asBoolean(payload?.compactsAutomatically) ?? false,82 compactsAutomatically: true,
91 updatedAt: activity.createdAt,83 updatedAt: entry.createdAt,
92 };84 };
93 }85 }
94 86
apps/web/src/lib/threadSort.test.ts+8−9
@@ -7,13 +7,14 @@ import { · 6 unmodified lines
7 ThreadId,7 ThreadId,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import type { Thread } from "../types";9import type { Thread } from "../types";
10import { makeThreadFixture } from "../test-fixtures";
10import { getLatestThreadForProject, sortThreads } from "./threadSort";11import { getLatestThreadForProject, sortThreads } from "./threadSort";
11 12
12const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");13const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
13const PROJECT_ID = ProjectId.make("project-1");14const PROJECT_ID = ProjectId.make("project-1");
14 15
15function makeThread(overrides: Partial<Thread> = {}): Thread {16function makeThread(overrides: Partial<Thread> = {}): Thread {
16 return {17 return makeThreadFixture({
17 id: ThreadId.make("thread-1"),18 id: ThreadId.make("thread-1"),
18 environmentId: LOCAL_ENVIRONMENT_ID,19 environmentId: LOCAL_ENVIRONMENT_ID,
19 projectId: PROJECT_ID,20 projectId: PROJECT_ID,
@@ -21,7 +22,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 1 unmodified line
21 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },22 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
22 runtimeMode: DEFAULT_RUNTIME_MODE,23 runtimeMode: DEFAULT_RUNTIME_MODE,
23 interactionMode: "default",24 interactionMode: "default",
24 session: null,25 runtime: null,
25 messages: [],26 messages: [],
26 proposedPlans: [],27 proposedPlans: [],
27 createdAt: "2026-03-09T10:00:00.000Z",28 createdAt: "2026-03-09T10:00:00.000Z",
@@ -30,13 +31,11 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 2 unmodified lines
30 settledAt: null,31 settledAt: null,
31 deletedAt: null,32 deletedAt: null,
32 updatedAt: "2026-03-09T10:00:00.000Z",33 updatedAt: "2026-03-09T10:00:00.000Z",
33 latestTurn: null,34 latestRun: null,
34 branch: null,35 branch: null,
35 worktreePath: null,36 worktreePath: null,
36 checkpoints: [],
37 activities: [],
38 ...overrides,37 ...overrides,
39 };38 });
40}39}
41 40
42describe("sortThreads", () => {41describe("sortThreads", () => {
@@ -51,7 +50,7 @@ describe("sortThreads", () => { · 8 unmodified lines
51 id: "message-1" as never,50 id: "message-1" as never,
52 role: "user",51 role: "user",
53 text: "older",52 text: "older",
54 turnId: null,53 runId: null,
55 createdAt: "2026-03-09T10:01:00.000Z",54 createdAt: "2026-03-09T10:01:00.000Z",
56 updatedAt: "2026-03-09T10:01:00.000Z",55 updatedAt: "2026-03-09T10:01:00.000Z",
57 streaming: false,56 streaming: false,
@@ -67,7 +66,7 @@ describe("sortThreads", () => { · 9 unmodified lines
67 id: "message-2" as never,66 id: "message-2" as never,
68 role: "user",67 role: "user",
69 text: "newer",68 text: "newer",
70 turnId: null,69 runId: null,
71 createdAt: "2026-03-09T10:06:00.000Z",70 createdAt: "2026-03-09T10:06:00.000Z",
72 updatedAt: "2026-03-09T10:06:00.000Z",71 updatedAt: "2026-03-09T10:06:00.000Z",
73 streaming: false,72 streaming: false,
@@ -95,7 +94,7 @@ describe("sortThreads", () => { · 21 unmodified lines
95 id: "message-1" as never,94 id: "message-1" as never,
96 role: "assistant",95 role: "assistant",
97 text: "assistant only",96 text: "assistant only",
98 turnId: null,97 runId: null,
99 createdAt: "2026-03-09T10:02:00.000Z",98 createdAt: "2026-03-09T10:02:00.000Z",
100 updatedAt: "2026-03-09T10:02:00.000Z",99 updatedAt: "2026-03-09T10:02:00.000Z",
101 streaming: false,100 streaming: false,
apps/web/src/session-logic.test.ts+123−1652
@@ -1,1688 +1,159 @@
1import {1import {
2 EventId,
3 MessageId,2 MessageId,
3 PlanId,
4 RunId,
4 ThreadId,5 ThreadId,
5 TurnId,6 TurnItemId,
6 type OrchestrationThreadActivity,7 type OrchestrationV2ProjectedTurnItem,
8 type OrchestrationV2TurnItem,
7} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import * as DateTime from "effect/DateTime";
8import { describe, expect, it } from "vite-plus/test";11import { describe, expect, it } from "vite-plus/test";
9 12
10import {13import {
11 deriveActiveWorkStartedAt,
12 deriveActivePlanState,
13 derivePendingApprovals,
14 derivePendingUserInputs,
15 deriveTimelineEntries,14 deriveTimelineEntries,
16 deriveWorkLogEntries,15 deriveTimelineEntriesFromVisibleTurnItems,
17 findLatestProposedPlan,16 findLatestProposedPlan,
18 findSidebarProposedPlan,17 isLatestRunSettled,
19 hasActionableProposedPlan,
20 isLatestTurnSettled,
21 workEntryIndicatesToolFailure,
22 workEntryIndicatesToolNeutralStatus,
23 workEntryIndicatesToolSuccess,
24} from "./session-logic";18} from "./session-logic";
25 19
26let nextActivityId = 0;20describe("V2 session presentation", () => {
27 21 it("uses run status as the settlement boundary", () => {
28function makeActivity(overrides: {22 const runId = RunId.make("run-1");
29 id?: string;23 expect(
30 createdAt?: string;24 isLatestRunSettled(
31 kind?: string;25 {
32 summary?: string;26 runId,
33 tone?: OrchestrationThreadActivity["tone"];27 status: "completed",
34 payload?: Record<string, unknown>;28 startedAt: "2026-06-20T00:00:00.000Z",
35 turnId?: string;29 completedAt: "2026-06-20T00:01:00.000Z",
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
51describe("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
180describe("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
312describe("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" }],
360 },30 },
361 }),31 null,
362 ];32 ),
363 33 ).toBe(true);
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
374describe("findLatestProposedPlan", () => {
375 it("prefers the latest proposed plan for the active turn", () => {
376 expect(34 expect(
377 findLatestProposedPlan(35 isLatestRunSettled(
378 [36 { runId, status: "running", startedAt: null, completedAt: null },
379 {37 { status: "running", activeRunId: runId },
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"),
408 ),38 ),
409 ).toEqual({39 ).toBe(false);
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 });
418 });40 });
419 41
420 it("falls back to the most recently updated proposed plan", () => {42 it("selects the latest proposed plan for a run", () => {
421 const latestPlan = findLatestProposedPlan(43 const runId = RunId.make("run-1");
44 const plan = findLatestProposedPlan(
422 [45 [
423 {46 {
424 id: "plan:thread-1:turn:turn-1",47 id: PlanId.make("plan-1"),
425 turnId: TurnId.make("turn-1"),48 runId,
426 planMarkdown: "# First",49 planMarkdown: "Plan",
50 status: "active",
427 implementedAt: null,51 implementedAt: null,
428 implementationThreadId: null,52 implementationThreadId: ThreadId.make("thread-implementation"),
429 createdAt: "2026-02-23T00:00:01.000Z",53 createdAt: "2026-06-20T00:00:00.000Z",
430 updatedAt: "2026-02-23T00:00:01.000Z",54 updatedAt: "2026-06-20T00: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",
440 },55 },
441 ],56 ],
442 null,57 runId,
443 );
444
445 expect(latestPlan?.planMarkdown).toBe("# Latest");
446 });
447});
448
449describe("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
479describe("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
576describe("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
693describe("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()" .'`,
1064 );58 );
59 expect(plan?.planMarkdown).toBe("Plan");
1065 });60 });
1066 61
1067 it("does not unwrap shell commands when no wrapper flag is present", () => {62 it("orders conversation and generic V2 work entries", () => {
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
1495describe("deriveTimelineEntries", () => {
1496 it("includes proposed plans alongside messages and work entries in chronological order", () => {
1497 const entries = deriveTimelineEntries(63 const entries = deriveTimelineEntries(
1498 [64 [
1499 {65 {
1500 id: MessageId.make("message-1"),66 id: "message-1" as never,
1501 role: "assistant",67 role: "user",
1502 text: "hello",68 text: "Hello",
1503 createdAt: "2026-02-23T00:00:01.000Z",69 runId: null,
1504 turnId: null,
1505 updatedAt: "2026-02-23T00:00:01.000Z",
1506 streaming: false,70 streaming: false,
71 createdAt: "2026-06-20T00:00:00.000Z",
72 updatedAt: "2026-06-20T00:00:00.000Z",
1507 },73 },
1508 ],74 ],
1509 [75 [],
1510 {76 [],
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 ],
1528 );77 );
1529 78 expect(entries.map((entry) => entry.kind)).toEqual(["message"]);
1530 expect(entries.map((entry) => entry.kind)).toEqual(["message", "proposed-plan", "work"]);79 });
1531 expect(entries[1]).toMatchObject({80
1532 kind: "proposed-plan",81 it("uses visible turn item order and keeps interruption lifecycle entries standalone", () => {
1533 proposedPlan: {82 const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
1534 planMarkdown: "# Ship it",83 const threadId = ThreadId.make("thread-visible");
1535 implementedAt: null,84 const runId = RunId.make("run-visible");
1536 implementationThreadId: null,85 const base = (id: string, ordinal: number) => ({
1537 },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: [],
1538 });144 });
1539 });
1540});
1541
1542describe("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 });
1564 145
1565 it("keeps context compaction activities as normal work log entries", () => {146 expect(entries.map((entry) => [entry.kind, entry.id])).toEqual([
1566 const entries = deriveWorkLogEntries([147 ["message", userItem.messageId],
1567 makeActivity({148 ["event", requestItem.id],
1568 id: "compaction-1",149 ["work", commandItem.id],
1569 turnId: "turn-1",150 ["event", resultItem.id],
1570 kind: "context-compaction",
1571 summary: "Context compacted",
1572 tone: "info",
1573 }),
1574 ]);151 ]);
1575 152 const commandEntry = entries[2];
1576 expect(entries).toHaveLength(1);153 expect(commandEntry?.kind).toBe("work");
1577 expect(entries[0]?.label).toBe("Context compacted");154 if (commandEntry?.kind === "work") {
1578 });155 expect(commandEntry.entry.projectedItem).toBe(visibleTurnItems[2]);
1579});156 expect(commandEntry.entry.structuredPayload).toBe(commandItem);
1580 157 }
1581describe("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
1629describe("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");
1687 });158 });
1688});159});
apps/web/src/session-logic.ts+392−1244
@@ -1,26 +1,24 @@
1import * as Option from "effect/Option";
2import * as Arr from "effect/Array";
3import {1import {
4 ApprovalRequestId,
5 isToolLifecycleItemType,
6 type OrchestrationLatestTurn,
7 type OrchestrationThreadActivity,
8 type OrchestrationProposedPlanId,
9 ProviderDriverKind,2 ProviderDriverKind,
10 type ToolLifecycleItemType,3 type OrchestrationV2ProjectedTurnItem,
11 type UserInputQuestion,4 type OrchestrationV2TurnItem,
5 type PlanId,
6 type RunId,
12 type ThreadId,7 type ThreadId,
13 type TurnId,
14} from "@t3tools/contracts";8} from "@t3tools/contracts";
15
16import type {9import type {
17 ChatMessage,10 ThreadCheckpointSummary,
18 ProposedPlan,11 ThreadPendingApproval,
19 SessionPhase,12 ThreadPendingUserInput,
20 Thread,13 ThreadProposedPlan,
21 ThreadSession,14 ThreadRunSummary,
22 TurnDiffSummary,15 ThreadRuntimeSummary,
23} from "./types";16 ThreadTodoPlan,
17 ThreadWorkEntry,
18} from "@t3tools/client-runtime/state/shell";
19
20import type { ChatMessage, ProposedPlan, SessionPhase, TurnDiffSummary } from "./types";
21import * as DateTime from "effect/DateTime";
24 22
25export type ProviderPickerKind = ProviderDriverKind;23export type ProviderPickerKind = ProviderDriverKind;
26 24
@@ -28,7 +26,6 @@ export const PROVIDER_OPTIONS: Array<{ · 1 unmodified line
28 value: ProviderPickerKind;26 value: ProviderPickerKind;
29 label: string;27 label: string;
30 available: boolean;28 available: boolean;
31 /** Shown on the model picker sidebar when relevant */
32 pickerSidebarBadge?: "new" | "soon";29 pickerSidebarBadge?: "new" | "soon";
33}> = [30}> = [
34 { value: ProviderDriverKind.make("codex"), label: "Codex", available: true },31 { value: ProviderDriverKind.make("codex"), label: "Codex", available: true },
@@ -45,221 +42,101 @@ export const PROVIDER_OPTIONS: Array<{ · 10 unmodified lines
45 available: true,42 available: true,
46 pickerSidebarBadge: "new",43 pickerSidebarBadge: "new",
47 },44 },
48 {45 { value: ProviderDriverKind.make("grok"), label: "Grok", available: true },
49 value: ProviderDriverKind.make("grok"),
50 label: "Grok",
51 available: true,
52 pickerSidebarBadge: "new",
53 },
54];46];
55 47
56export type WorkLogToolLifecycleStatus =48export type WorkLogToolLifecycleStatus = ThreadWorkEntry["toolLifecycleStatus"];
57 | "inProgress"
58 | "completed"
59 | "failed"
60 | "declined"
61 | "stopped";
62
63export 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
83interface DerivedWorkLogEntry extends WorkLogEntry {
84 activityKind: OrchestrationThreadActivity["kind"];
85 collapseKey?: string;
86 toolCallId?: string;
87}
88 49
89export interface PendingApproval {50export interface WorkLogEntry extends Omit<
90 requestId: ApprovalRequestId;51 ThreadWorkEntry,
91 requestKind: "command" | "file-read" | "file-change";52 "structuredPayload" | "runId" | "itemType" | "toolLifecycleStatus"
92 createdAt: string;53> {
93 detail?: string;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;
94}60}
95 61
96export interface PendingUserInput {62export type PendingApproval = ThreadPendingApproval;
97 requestId: ApprovalRequestId;63export type PendingUserInput = ThreadPendingUserInput;
98 createdAt: string;
99 questions: ReadonlyArray<UserInputQuestion>;
100}
101 64
102export interface ActivePlanState {65export interface ActivePlanState {
103 createdAt: string;66 readonly createdAt: string;
104 turnId: TurnId | null;67 readonly runId: RunId | null;
105 explanation?: string | null;68 readonly explanation?: string | null;
106 steps: Array<{69 readonly steps: Array<{
107 step: string;70 readonly step: string;
108 status: "pending" | "inProgress" | "completed";71 readonly status: "pending" | "inProgress" | "completed";
109 }>;72 }>;
110}73}
111 74
112export interface LatestProposedPlanState {75export interface LatestProposedPlanState {
113 id: OrchestrationProposedPlanId;76 readonly id: PlanId;
114 createdAt: string;77 readonly createdAt: string;
115 updatedAt: string;78 readonly updatedAt: string;
116 turnId: TurnId | null;79 readonly runId: RunId | null;
117 planMarkdown: string;80 readonly planMarkdown: string;
118 implementedAt: string | null;81 readonly implementedAt: string | null;
119 implementationThreadId: ThreadId | null;82 readonly implementationThreadId: ThreadId | null;
83 readonly status: ThreadProposedPlan["status"];
120}84}
121 85
122export type TimelineEntry =86export type TimelineEntry =
123 | {87 | {
124 id: string;88 readonly id: string;
125 kind: "message";89 readonly kind: "message";
126 createdAt: string;90 readonly createdAt: string;
127 message: ChatMessage;91 readonly message: ChatMessage;
92 }
93 | {
94 readonly id: string;
95 readonly kind: "proposed-plan";
96 readonly createdAt: string;
97 readonly proposedPlan: ProposedPlan;
128 }98 }
129 | {99 | {
130 id: string;100 readonly id: string;
131 kind: "proposed-plan";101 readonly kind: "work";
132 createdAt: string;102 readonly createdAt: string;
133 proposedPlan: ProposedPlan;103 readonly entry: WorkLogEntry;
134 }104 }
135 | {105 | {
136 id: string;106 readonly id: string;
137 kind: "work";107 readonly kind: "event";
138 createdAt: string;108 readonly createdAt: string;
139 entry: WorkLogEntry;109 readonly projectedItem: OrchestrationV2ProjectedTurnItem;
140 };110 };
141 111
142export function workLogEntryIsToolLike(entry: WorkLogEntry): boolean {112export function workLogEntryIsToolLike(entry: WorkLogEntry): boolean {
143 if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") {113 return (
144 return true;114 entry.tone === "tool" ||
145 }115 entry.tone === "thinking" ||
146 if (entry.command !== undefined && entry.command.trim().length > 0) {116 entry.tone === "error" ||
147 return true;117 entry.command !== undefined ||
148 }118 entry.requestKind !== undefined
149 if (entry.requestKind !== undefined) {119 );
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`. */
156function 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;
199}120}
200 121
201/** True when the row should show a failure affordance (explicit status/tone or error-shaped tool output). */
202export function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean {122export function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean {
203 if (entry.tone === "error") {123 return (
204 return true;124 entry.tone === "error" ||
205 }125 entry.toolLifecycleStatus === "failed" ||
206 const ls = entry.toolLifecycleStatus;126 entry.toolLifecycleStatus === "declined"
207 if (ls === "failed" || ls === "declined") {127 );
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);
225}128}
226 129
227/** Tool/command row completed without failure (blue check affordance). */
228export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean {130export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean {
229 if (!workLogEntryIsToolLike(entry)) {131 return workLogEntryIsToolLike(entry) && entry.toolLifecycleStatus === "completed";
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;
249}132}
250 133
251/** Tool-like row with neither clear success nor failure (empty, incomplete, in progress, etc.). */
252export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolean {134export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolean {
253 if (!workLogEntryIsToolLike(entry)) {135 return (
254 return false;136 workLogEntryIsToolLike(entry) &&
255 }137 !workEntryIndicatesToolFailure(entry) &&
256 if (workEntryIndicatesToolFailure(entry)) {138 !workEntryIndicatesToolSuccess(entry)
257 return false;139 );
258 }
259 if (workEntryIndicatesToolSuccess(entry)) {
260 return false;
261 }
262 return true;
263}140}
264 141
265export function formatDuration(durationMs: number): string {142export function formatDuration(durationMs: number): string {
@@ -267,7 +144,6 @@ export function formatDuration(durationMs: number): string { · 1 unmodified line
267 if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`;144 if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`;
268 if (durationMs < 10_000) {145 if (durationMs < 10_000) {
269 const tenths = Math.round(durationMs / 100) / 10;146 const tenths = Math.round(durationMs / 100) / 10;
270 // 9.95s+ rounds up to the next bucket — render "10s", not "10.0s".
271 return tenths >= 10 ? "10s" : `${tenths.toFixed(1)}s`;147 return tenths >= 10 ? "10s" : `${tenths.toFixed(1)}s`;
272 }148 }
273 if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`;149 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
282 if (!endIso) return null;158 if (!endIso) return null;
283 const startedAt = Date.parse(startIso);159 const startedAt = Date.parse(startIso);
284 const endedAt = Date.parse(endIso);160 const endedAt = Date.parse(endIso);
285 if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) {161 if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) return null;
286 return null;
287 }
288 return formatDuration(endedAt - startedAt);162 return formatDuration(endedAt - startedAt);
289}163}
290 164
291type LatestTurnTiming = Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt">;165export function isLatestRunSettled(
292type SessionActivityState = Pick<NonNullable<Thread["session"]>, "status" | "activeTurnId">;166 latestRun: Pick<ThreadRunSummary, "runId" | "startedAt" | "completedAt" | "status"> | null,
293 167 runtime: Pick<ThreadRuntimeSummary, "status" | "activeRunId"> | null,
294export function isLatestTurnSettled(
295 latestTurn: LatestTurnTiming | null,
296 session: SessionActivityState | null,
297): boolean {168): boolean {
298 if (!latestTurn?.startedAt) return false;169 if (latestRun === null) return false;
299 if (!latestTurn.completedAt) return false;170 if (
300 if (!session) return true;171 latestRun.status === "starting" ||
301 if (session.status === "running") return false;172 latestRun.status === "running" ||
302 return true;173 latestRun.status === "waiting"
174 )
175 return false;
176 return runtime?.activeRunId !== latestRun.runId;
303}177}
304 178
305export function deriveActiveWorkStartedAt(179export function deriveActiveWorkStartedAt(
306 latestTurn: LatestTurnTiming | null,180 latestRun: Pick<ThreadRunSummary, "runId" | "startedAt" | "completedAt" | "status"> | null,
307 session: SessionActivityState | null,181 runtime: Pick<ThreadRuntimeSummary, "status" | "activeRunId"> | null,
308 sendStartedAt: string | null,182 sendStartedAt: string | null,
309): string | null {183): string | null {
310 const runningTurnId = session?.status === "running" ? session.activeTurnId : null;184 if (runtime?.activeRunId !== null && runtime?.activeRunId !== undefined) {
311 if (runningTurnId !== null) {185 return latestRun?.runId === runtime.activeRunId
312 if (latestTurn?.turnId === runningTurnId) {186 ? (latestRun.startedAt ?? sendStartedAt)
313 return latestTurn.startedAt ?? sendStartedAt;187 : sendStartedAt;
314 }
315 return sendStartedAt;
316 }188 }
317 if (!isLatestTurnSettled(latestTurn, session)) {189 return isLatestRunSettled(latestRun, runtime)
318 return latestTurn?.startedAt ?? sendStartedAt;190 ? sendStartedAt
319 }191 : (latestRun?.startedAt ?? sendStartedAt);
320 return sendStartedAt;
321}
322
323function 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
339function 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 );
353}192}
354 193
355export function derivePendingApprovals(194export function derivePendingApprovals(
356 activities: ReadonlyArray<OrchestrationThreadActivity>,195 approvals: ReadonlyArray<ThreadPendingApproval>,
357): PendingApproval[] {196): ThreadPendingApproval[] {
358 const openByRequestId = new Map<ApprovalRequestId, PendingApproval>();197 return [...approvals].toSorted((left, right) => left.createdAt.localeCompare(right.createdAt));
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
411function 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;
459}198}
460 199
461export function derivePendingUserInputs(200export function derivePendingUserInputs(
462 activities: ReadonlyArray<OrchestrationThreadActivity>,201 inputs: ReadonlyArray<ThreadPendingUserInput>,
463): PendingUserInput[] {202): ThreadPendingUserInput[] {
464 const openByRequestId = new Map<ApprovalRequestId, PendingUserInput>();203 return [...inputs].toSorted((left, right) => left.createdAt.localeCompare(right.createdAt));
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 );
508}204}
509 205
510export function deriveActivePlanState(206export function deriveActivePlanState(
511 activities: ReadonlyArray<OrchestrationThreadActivity>,207 plans: ReadonlyArray<ThreadTodoPlan>,
512 latestTurnId: TurnId | undefined,208 latestRunId: RunId | undefined,
513): ActivePlanState | null {209): ActivePlanState | null {
514 const ordered = [...activities].toSorted(compareActivitiesByOrder);210 const plan =
515 const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated");211 [...plans].toReversed().find((candidate) => candidate.runId === latestRunId) ??
516 // Prefer plan from the current turn; fall back to the most recent plan from any turn212 plans.at(-1) ??
517 // so that TodoWrite tasks persist across follow-up messages.213 null;
518 const latest = Option.firstSomeOf([214 if (plan === null || plan.steps.length === 0) return null;
519 ...(latestTurnId215 return {
520 ? Arr.findLast(allPlanActivities, (activity) => activity.turnId === latestTurnId)216 createdAt: plan.updatedAt,
521 : Option.none()),217 runId: plan.runId,
522 Arr.last(allPlanActivities),218 explanation: plan.explanation,
523 ]).pipe(Option.getOrNull);219 steps: plan.steps.map(({ step, status }) => ({ step, status })),
524 if (!latest) {220 };
525 return null;221}
526 }222
527 const payload =223function toLatestProposedPlanState(plan: ThreadProposedPlan): LatestProposedPlanState {
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 }
557 return {224 return {
558 createdAt: latest.createdAt,225 id: plan.id,
559 turnId: latest.turnId,226 createdAt: plan.createdAt,
560 ...(payload && "explanation" in payload227 updatedAt: plan.updatedAt,
561 ? { explanation: payload.explanation as string | null }228 runId: plan.runId,
562 : {}),229 planMarkdown: plan.planMarkdown,
563 steps,230 implementedAt: plan.implementedAt,
231 implementationThreadId: plan.implementationThreadId,
232 status: plan.status,
564 };233 };
565}234}
566 235
567export function findLatestProposedPlan(236export function findLatestProposedPlan(
568 proposedPlans: ReadonlyArray<ProposedPlan>,237 plans: ReadonlyArray<ThreadProposedPlan>,
569 latestTurnId: TurnId | string | null | undefined,238 latestRunId: RunId | string | null | undefined,
570): LatestProposedPlanState | null {239): LatestProposedPlanState | null {
571 if (latestTurnId) {240 const candidates = latestRunId ? plans.filter((plan) => plan.runId === latestRunId) : plans;
572 const matchingTurnPlan = [...proposedPlans]241 const plan = [...(candidates.length > 0 ? candidates : plans)]
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]
585 .toSorted(242 .toSorted(
586 (left, right) =>243 (left, right) =>
587 left.updatedAt.localeCompare(right.updatedAt) || left.id.localeCompare(right.id),244 left.updatedAt.localeCompare(right.updatedAt) || left.id.localeCompare(right.id),
588 )245 )
589 .at(-1);246 .at(-1);
590 if (!latestPlan) {247 return plan === undefined ? null : toLatestProposedPlanState(plan);
591 return null;
592 }
593
594 return toLatestProposedPlanState(latestPlan);
595}248}
596 249
597export function findSidebarProposedPlan(input: {250export function findSidebarProposedPlan(input: {
598 threads: ReadonlyArray<Pick<Thread, "id" | "proposedPlans">>;251 readonly threads: ReadonlyArray<
599 latestTurn: Pick<OrchestrationLatestTurn, "turnId" | "sourceProposedPlan"> | null;252 Pick<
600 latestTurnSettled: boolean;253 { readonly id: ThreadId; readonly proposedPlans: ReadonlyArray<ThreadProposedPlan> },
601 threadId: ThreadId | string | null | undefined;254 "id" | "proposedPlans"
255 >
256 >;
257 readonly latestRun: Pick<ThreadRunSummary, "runId" | "sourcePlanRef"> | null;
258 readonly latestRunSettled: boolean;
259 readonly threadId: ThreadId | string | null | undefined;
602}): LatestProposedPlanState | null {260}): 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 =
604 input.threads.find((thread) => thread.id === input.threadId)?.proposedPlans ?? [];269 input.threads.find((thread) => thread.id === input.threadId)?.proposedPlans ?? [];
605 270 return findLatestProposedPlan(activePlans, input.latestRun?.runId);
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);
619}271}
620 272
621export function hasActionableProposedPlan(273export function hasActionableProposedPlan(
622 proposedPlan: LatestProposedPlanState | Pick<ProposedPlan, "implementedAt"> | null,274 plan: LatestProposedPlanState | Pick<ThreadProposedPlan, "implementedAt"> | null,
623): boolean {
624 return proposedPlan !== null && proposedPlan.implementedAt === null;
625}
626
627export 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
646function 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
658function 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
677function 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
766function 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
781function shouldCollapseToolLifecycleEntries(
782 previous: DerivedWorkLogEntry,
783 next: DerivedWorkLogEntry,
784): boolean {275): boolean {
785 if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") {276 return plan !== null && plan.implementedAt === null;
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
806function 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 };
836}277}
837 278
838function mergeChangedFiles(279export function deriveWorkLogEntries(entries: ReadonlyArray<ThreadWorkEntry>): WorkLogEntry[] {
839 previous: ReadonlyArray<string> | undefined,280 return entries.map((entry) => ({ ...entry, sourceItemType: entry.itemType }));
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
849function 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");
863}281}
864 282
865function normalizeCompactToolLabel(value: string): string {283export function deriveTimelineEntries(
866 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();284 messages: ReadonlyArray<ChatMessage>,
867}285 proposedPlans: ReadonlyArray<ThreadProposedPlan>,
868 286 workEntries: ReadonlyArray<WorkLogEntry>,
869function toLatestProposedPlanState(proposedPlan: ProposedPlan): LatestProposedPlanState {287): TimelineEntry[] {
870 return {288 return [
871 id: proposedPlan.id,289 ...messages.map(
872 createdAt: proposedPlan.createdAt,290 (message): TimelineEntry => ({
873 updatedAt: proposedPlan.updatedAt,291 id: message.id,
874 turnId: proposedPlan.turnId,292 kind: "message",
875 planMarkdown: proposedPlan.planMarkdown,293 createdAt: message.createdAt,
876 implementedAt: proposedPlan.implementedAt,294 message,
877 implementationThreadId: proposedPlan.implementationThreadId,295 }),
878 };296 ),
879}297 ...proposedPlans.map(
880 298 (proposedPlan): TimelineEntry => ({
881function asRecord(value: unknown): Record<string, unknown> | null {299 id: proposedPlan.id,
882 return value && typeof value === "object" ? (value as Record<string, unknown>) : null;300 kind: "proposed-plan",
883}301 createdAt: proposedPlan.createdAt,
884 302 proposedPlan,
885function asTrimmedString(value: unknown): string | null {303 }),
886 if (typeof value !== "string") {304 ),
887 return null;305 ...workEntries.map(
888 }306 (entry): TimelineEntry => ({
889 const trimmed = value.trim();307 id: entry.id,
890 return trimmed.length > 0 ? trimmed : null;308 kind: "work",
891}309 createdAt: entry.createdAt,
892 310 entry,
893function asNumber(value: unknown): number | null {311 }),
894 return typeof value === "number" && Number.isFinite(value) ? value : null;312 ),
895}313 ].toSorted(
896 314 (left, right) =>
897function trimMatchingOuterQuotes(value: string): string {315 left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id),
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
909function 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
920function 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
952const 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
967function findShellWrapperSpec(shell: string) {
968 return SHELL_WRAPPER_SPECS.find((spec) =>
969 (spec.executables as ReadonlyArray<string>).includes(shell),
970 );316 );
971}317}
972 318
973function unwrapCommandRemainder(value: string, wrapperFlagPattern: RegExp): string | null {319const STANDALONE_V2_ITEM_TYPES = new Set<OrchestrationV2ProjectedTurnItem["item"]["type"]>([
974 const match = wrapperFlagPattern.exec(value);320 "approval_request",
975 if (!match) {321 "compaction",
976 return null;322 "fork",
977 }323 "handoff",
978 324 "run_interrupt_request",
979 const command = value.slice(match.index + match[0].length).trim();325 "run_interrupt_result",
980 if (command.length === 0) {326 "subagent",
981 return null;327 "todo_list",
982 }328 "user_input_request",
983 329]);
984 const unwrapped = trimMatchingOuterQuotes(command);330
985 return unwrapped.length > 0 ? unwrapped : null;331function projectedItemCreatedAt(row: OrchestrationV2ProjectedTurnItem): string {
986}332 return DateTime.formatIso(row.item.startedAt ?? row.item.updatedAt);
987 333}
988function unwrapKnownShellCommandWrapper(value: string): string {334
989 const split = splitExecutableAndRest(value);335function projectedWorkEntryStatus(
990 if (!split || split.rest.length === 0) {336 item: OrchestrationV2TurnItem,
991 return value;337): NonNullable<WorkLogEntry["toolLifecycleStatus"]> {
992 }338 switch (item.status) {
993 339 case "pending":
994 const shell = executableBasename(split.executable);340 case "running":
995 if (!shell) {341 case "waiting":
996 return value;342 return "inProgress";
997 }343 case "completed":
998 344 return "completed";
999 const spec = findShellWrapperSpec(shell);345 case "failed":
1000 if (!spec) {346 return "failed";
1001 return value;347 case "cancelled":
1002 }348 case "interrupted":
1003 349 return "stopped";
1004 return unwrapCommandRemainder(split.rest, spec.wrapperFlagPattern) ?? value;350 }
1005}351}
1006 352
1007function formatCommandArrayPart(value: string): string {353function projectedWorkEntryTone(item: OrchestrationV2TurnItem): WorkLogEntry["tone"] {
1008 return /[\s"'`]/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value;354 if (item.status === "failed") return "error";
1009}355 if (item.type === "reasoning") return "thinking";
1010 356 switch (item.type) {
1011function formatCommandValue(value: unknown): string | null {357 case "command_execution":
1012 const direct = asTrimmedString(value);358 case "file_change":
1013 if (direct) {359 case "file_search":
1014 return direct;360 case "web_search":
1015 }361 case "dynamic_tool":
1016 if (!Array.isArray(value)) {362 case "subagent":
1017 return null;363 return "tool";
1018 }364 default:
1019 const parts: Array<string> = [];365 return "info";
1020 for (const entry of value) {366 }
1021 const part = asTrimmedString(entry);367}
1022 if (part !== null) {368
1023 parts.push(part);369function projectedWorkEntry(row: OrchestrationV2ProjectedTurnItem): WorkLogEntry {
1024 }370 const { item } = row;
1025 }371 const title = item.title?.trim() || null;
1026 if (parts.length === 0) {372 const common = {
1027 return null;373 id: item.id,
1028 }374 createdAt: projectedItemCreatedAt(row),
1029 return parts.map((part) => formatCommandArrayPart(part)).join(" ");375 runId: item.runId,
1030}376 tone: projectedWorkEntryTone(item),
1031 377 itemType: item.type,
1032function normalizeCommandValue(value: unknown): string | null {378 toolLifecycleStatus: projectedWorkEntryStatus(item),
1033 const formatted = formatCommandValue(value);379 structuredPayload: item,
1034 return formatted ? unwrapKnownShellCommandWrapper(formatted) : null;380 projectedItem: row,
1035}381 } as const;
1036 382
1037function toRawToolCommand(value: unknown, normalizedCommand: string | null): string | null {383 switch (item.type) {
1038 const formatted = formatCommandValue(value);384 case "reasoning":
1039 if (!formatted || normalizedCommand === null) {385 return {
1040 return null;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 };
1041 }445 }
1042 return formatted === normalizedCommand ? null : formatted;
1043}446}
1044 447
1045function extractToolCommand(payload: Record<string, unknown> | null): {448/**
1046 command: string | null;449 * Builds the web timeline in the exact order committed by `visibleTurnItems`.
1047 rawCommand: string | null;450 * Committed rows are presented directly from their projected item. Optimistic
1048} {451 * messages are the only client-owned entries appended to that sequence.
1049 const data = asRecord(payload?.data);452 */
1050 const item = asRecord(data?.item);453export function deriveTimelineEntriesFromVisibleTurnItems(input: {
1051 const itemResult = asRecord(item?.result);454 readonly visibleTurnItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem>;
1052 const itemInput = asRecord(item?.input);455 readonly optimisticMessages: ReadonlyArray<ChatMessage>;
1053 const itemType = asTrimmedString(payload?.itemType);456 readonly attachmentUrlById?: ReadonlyMap<string, string>;
1054 const detail = asTrimmedString(payload?.detail);457}): TimelineEntry[] {
1055 const candidates: unknown[] = [458 const committedMessageIds = new Set<string>();
1056 item?.command,459 const entries: TimelineEntry[] = [];
1057 itemInput?.command,460
1058 itemResult?.command,461 for (const row of input.visibleTurnItems) {
1059 data?.command,462 const { item } = row;
1060 itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null,463 const createdAt = projectedItemCreatedAt(row);
1061 ];464 if (item.type === "user_message" || item.type === "assistant_message") {
1062 465 const message: ChatMessage = {
1063 for (const candidate of candidates) {466 id: item.messageId,
1064 const command = normalizeCommandValue(candidate);467 role: item.type === "user_message" ? "user" : "assistant",
1065 if (!command) {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 });
1066 continue;484 continue;
1067 }485 }
1068 return {
1069 command,
1070 rawCommand: toRawToolCommand(candidate, command),
1071 };
1072 }
1073 486
1074 return {487 if (item.type === "proposed_plan") {
1075 command: null,488 const proposedPlan = {
1076 rawCommand: null,489 id: item.planId,
1077 };490 runId: item.runId,
1078}491 planMarkdown: item.markdown,
1079 492 status: "active" as const,
1080function extractToolTitle(payload: Record<string, unknown> | null): string | null {493 implementedAt: null,
1081 return asTrimmedString(payload?.title);494 implementationThreadId: null,
1082}495 createdAt,
1083 496 updatedAt: DateTime.formatIso(item.updatedAt),
1084function extractToolCallId(payload: Record<string, unknown> | null): string | null {497 };
1085 const data = asRecord(payload?.data);498 entries.push({ id: item.id, kind: "proposed-plan", createdAt, proposedPlan });
1086 return asTrimmedString(data?.toolCallId);
1087}
1088
1089function normalizeInlinePreview(value: string): string {
1090 return value.replace(/\s+/g, " ").trim();
1091}
1092
1093function 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
1100function 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
1108function 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
1126function 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
1152function 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
1164function 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
1192function 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
1213function 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
1222function 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
1235function 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
1244function 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)) {
1283 continue;499 continue;
1284 }500 }
1285 collectChangedFiles(record[nestedKey], target, seen, depth + 1);
1286 if (target.length >= 12) {
1287 return;
1288 }
1289 }
1290}
1291
1292function 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}
1298 501
1299function compareActivitiesByOrder(502 if (STANDALONE_V2_ITEM_TYPES.has(item.type)) {
1300 left: OrchestrationThreadActivity,503 entries.push({ id: item.id, kind: "event", createdAt, projectedItem: row });
1301 right: OrchestrationThreadActivity,504 continue;
1302): number {
1303 if (left.sequence !== undefined && right.sequence !== undefined) {
1304 if (left.sequence !== right.sequence) {
1305 return left.sequence - right.sequence;
1306 }505 }
1307 } else if (left.sequence !== undefined) {
1308 return 1;
1309 } else if (right.sequence !== undefined) {
1310 return -1;
1311 }
1312 506
1313 const createdAtComparison = left.createdAt.localeCompare(right.createdAt);507 entries.push({ id: item.id, kind: "work", createdAt, entry: projectedWorkEntry(row) });
1314 if (createdAtComparison !== 0) {
1315 return createdAtComparison;
1316 }508 }
1317 509
1318 const lifecycleRankComparison =510 for (const message of input.optimisticMessages) {
1319 compareActivityLifecycleRank(left.kind) - compareActivityLifecycleRank(right.kind);511 if (!committedMessageIds.has(message.id)) {
1320 if (lifecycleRankComparison !== 0) {512 entries.push({
1321 return lifecycleRankComparison;513 id: message.id,
514 kind: "message",
515 createdAt: message.createdAt,
516 message,
517 });
518 }
1322 }519 }
1323 520
1324 return left.id.localeCompare(right.id);521 return entries;
1325}522}
1326 523
1327function compareActivityLifecycleRank(kind: string): number {524export function inferCheckpointTurnCountByRunId(
1328 if (kind.endsWith(".started") || kind === "tool.started") {525 summaries: ReadonlyArray<ThreadCheckpointSummary>,
1329 return 0;526): Record<string, number> {
1330 }527 return Object.fromEntries(
1331 if (kind.endsWith(".progress") || kind.endsWith(".updated")) {528 summaries.flatMap((summary) =>
1332 return 1;529 summary.runId === null ? [] : [[summary.runId, summary.checkpointTurnCount] as const],
1333 }530 ),
1334 if (kind.endsWith(".completed") || kind.endsWith(".resolved")) {
1335 return 2;
1336 }
1337 return 1;
1338}
1339
1340export 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),
1365 );531 );
1366}532}
1367 533
1368export function inferCheckpointTurnCountByTurnId(534export function derivePhase(runtime: ThreadRuntimeSummary | null): SessionPhase {
1369 summaries: ReadonlyArray<TurnDiffSummary>,535 if (runtime === null) return "disconnected";
1370): Record<TurnId, number> {536 if (runtime.status === "starting" || runtime.status === "queued") return "connecting";
1371 const sorted = [...summaries].toSorted((a, b) => a.completedAt.localeCompare(b.completedAt));537 if (runtime.status === "running" || runtime.status === "waiting") return "running";
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
1381export 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";
1392 return "ready";538 return "ready";
1393}539}
540
541export type { TurnDiffSummary };
apps/web/src/state/entities.ts+34−29
@@ -3,21 +3,14 @@ import type { · 2 unmodified lines
3 EnvironmentProject,3 EnvironmentProject,
4 EnvironmentThread,4 EnvironmentThread,
5 EnvironmentThreadShell,5 EnvironmentThreadShell,
6 ThreadConversationMessage,
7 ThreadProposedPlan,
8 ThreadRuntimeSummary,
9 ThreadWorkEntry,
6} from "@t3tools/client-runtime/state/shell";10} from "@t3tools/client-runtime/state/shell";
7import {11import { mergeEnvironmentThread } from "@t3tools/client-runtime/state/threads";
8 type EnvironmentThreadStatus,12import type { ScopedProjectRef, ScopedThreadRef, ServerConfig } from "@t3tools/contracts";
9 mergeEnvironmentThread,13import type { EnvironmentId, OrchestrationV2ProjectedTurnItem, ThreadId } from "@t3tools/contracts";
10} from "@t3tools/client-runtime/state/threads";
11import type {
12 OrchestrationMessage,
13 OrchestrationProposedPlan,
14 OrchestrationSession,
15 OrchestrationThreadActivity,
16 ScopedProjectRef,
17 ScopedThreadRef,
18 ServerConfig,
19} from "@t3tools/contracts";
20import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
21import { Atom } from "effect/unstable/reactivity";14import { Atom } from "effect/unstable/reactivity";
22import { useMemo } from "react";15import { useMemo } from "react";
23import { appAtomRegistry } from "../rpc/atomRegistry";16import { appAtomRegistry } from "../rpc/atomRegistry";
@@ -28,9 +21,10 @@ import { environmentThreadDetails, environmentThreadShells } from "./threads"; · 4 unmodified lines
28 21
29const EMPTY_PROJECT_REFS: ReadonlyArray<ScopedProjectRef> = Object.freeze([]);22const EMPTY_PROJECT_REFS: ReadonlyArray<ScopedProjectRef> = Object.freeze([]);
30const EMPTY_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]);23const EMPTY_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]);
31const EMPTY_MESSAGES: ReadonlyArray<OrchestrationMessage> = Object.freeze([]);24const EMPTY_MESSAGES: ReadonlyArray<ThreadConversationMessage> = Object.freeze([]);
32const EMPTY_ACTIVITIES: ReadonlyArray<OrchestrationThreadActivity> = Object.freeze([]);25const EMPTY_WORK_ENTRIES: ReadonlyArray<ThreadWorkEntry> = Object.freeze([]);
33const EMPTY_PROPOSED_PLANS: ReadonlyArray<OrchestrationProposedPlan> = Object.freeze([]);26const EMPTY_PROPOSED_PLANS: ReadonlyArray<ThreadProposedPlan> = Object.freeze([]);
27const EMPTY_VISIBLE_TURN_ITEMS: ReadonlyArray<OrchestrationV2ProjectedTurnItem> = Object.freeze([]);
34 28
35const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe(29const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe(
36 Atom.withLabel("web-project:empty"),30 Atom.withLabel("web-project:empty"),
@@ -53,14 +47,17 @@ const EMPTY_THREAD_STATUS_ATOM = Atom.make<EnvironmentThreadStatus>("empty").pip · 16 unmodified lines
53const EMPTY_MESSAGES_ATOM = Atom.make(EMPTY_MESSAGES).pipe(47const EMPTY_MESSAGES_ATOM = Atom.make(EMPTY_MESSAGES).pipe(
54 Atom.withLabel("web-thread-messages:empty"),48 Atom.withLabel("web-thread-messages:empty"),
55);49);
56const EMPTY_ACTIVITIES_ATOM = Atom.make(EMPTY_ACTIVITIES).pipe(50const EMPTY_WORK_ENTRIES_ATOM = Atom.make(EMPTY_WORK_ENTRIES).pipe(
57 Atom.withLabel("web-thread-activities:empty"),51 Atom.withLabel("web-thread-work-entries:empty"),
58);52);
59const EMPTY_PROPOSED_PLANS_ATOM = Atom.make(EMPTY_PROPOSED_PLANS).pipe(53const EMPTY_PROPOSED_PLANS_ATOM = Atom.make(EMPTY_PROPOSED_PLANS).pipe(
60 Atom.withLabel("web-thread-proposed-plans:empty"),54 Atom.withLabel("web-thread-proposed-plans:empty"),
61);55);
62const EMPTY_SESSION_ATOM = Atom.make<OrchestrationSession | null>(null).pipe(56const EMPTY_VISIBLE_TURN_ITEMS_ATOM = Atom.make(EMPTY_VISIBLE_TURN_ITEMS).pipe(
63 Atom.withLabel("web-thread-session:empty"),57 Atom.withLabel("web-thread-visible-turn-items:empty"),
58);
59const EMPTY_RUNTIME_ATOM = Atom.make<ThreadRuntimeSummary | null>(null).pipe(
60 Atom.withLabel("web-thread-runtime:empty"),
64);61);
65 62
66export const activeEnvironmentIdAtom = Atom.make<EnvironmentId | null>(null).pipe(63export const activeEnvironmentIdAtom = Atom.make<EnvironmentId | null>(null).pipe(
@@ -186,31 +183,39 @@ export function useThread( · 119 unmodified lines
186 183
187export function useThreadMessages(184export function useThreadMessages(
188 ref: ScopedThreadRef | null,185 ref: ScopedThreadRef | null,
189): ReadonlyArray<OrchestrationMessage> {186): ReadonlyArray<ThreadConversationMessage> {
190 return useAtomValue(187 return useAtomValue(
191 ref === null ? EMPTY_MESSAGES_ATOM : environmentThreadDetails.messagesAtom(ref),188 ref === null ? EMPTY_MESSAGES_ATOM : environmentThreadDetails.messagesAtom(ref),
192 );189 );
193}190}
194 191
195export function useThreadActivities(192export function useThreadWorkEntries(ref: ScopedThreadRef | null): ReadonlyArray<ThreadWorkEntry> {
196 ref: ScopedThreadRef | null,
197): ReadonlyArray<OrchestrationThreadActivity> {
198 return useAtomValue(193 return useAtomValue(
199 ref === null ? EMPTY_ACTIVITIES_ATOM : environmentThreadDetails.activitiesAtom(ref),194 ref === null ? EMPTY_WORK_ENTRIES_ATOM : environmentThreadDetails.workEntriesAtom(ref),
200 );195 );
201}196}
202 197
203export function useThreadProposedPlans(198export function useThreadProposedPlans(
204 ref: ScopedThreadRef | null,199 ref: ScopedThreadRef | null,
205): ReadonlyArray<OrchestrationProposedPlan> {200): ReadonlyArray<ThreadProposedPlan> {
206 return useAtomValue(201 return useAtomValue(
207 ref === null ? EMPTY_PROPOSED_PLANS_ATOM : environmentThreadDetails.proposedPlansAtom(ref),202 ref === null ? EMPTY_PROPOSED_PLANS_ATOM : environmentThreadDetails.proposedPlansAtom(ref),
208 );203 );
209}204}
210 205
211export function useThreadSession(ref: ScopedThreadRef | null): OrchestrationSession | null {206export 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
216export function useThreadRuntime(ref: ScopedThreadRef | null): ThreadRuntimeSummary | null {
212 return useAtomValue(217 return useAtomValue(
213 ref === null ? EMPTY_SESSION_ATOM : environmentThreadDetails.sessionAtom(ref),218 ref === null ? EMPTY_RUNTIME_ATOM : environmentThreadDetails.runtimeAtom(ref),
214 );219 );
215}220}
216 221
apps/web/src/state/queries.ts+3−10
@@ -9,15 +9,8 @@ import { · 8 unmodified lines
9 type EnvironmentThreadSearchMatch,9 type EnvironmentThreadSearchMatch,
10} from "@t3tools/client-runtime/state/thread-search";10} from "@t3tools/client-runtime/state/thread-search";
11import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs";11import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs";
12import type {12import type { EnvironmentId, ThreadId, VcsListRefsResult, VcsRef } from "@t3tools/contracts";
13 EnvironmentId,13import type { EnvironmentThread } from "@t3tools/client-runtime/state/shell";
14 OrchestrationThread,
15 ProjectContentMatch,
16 ProjectEntryKind,
17 ThreadId,
18 VcsListRefsResult,
19 VcsRef,
20} from "@t3tools/contracts";
21import * as Cause from "effect/Cause";14import * as Cause from "effect/Cause";
22import * as Option from "effect/Option";15import * as Option from "effect/Option";
23import { AsyncResult, Atom } from "effect/unstable/reactivity";16import { AsyncResult, Atom } from "effect/unstable/reactivity";
@@ -56,7 +49,7 @@ const threadSearchResultsAtom = createThreadSearchResultsAtomFamily({ · 32 unmodified lines
56});49});
57 50
58export interface ThreadDetailView {51export interface ThreadDetailView {
59 readonly data: OrchestrationThread | null;52 readonly data: EnvironmentThread | null;
60 readonly error: string | null;53 readonly error: string | null;
61 readonly isPending: boolean;54 readonly isPending: boolean;
62 readonly isDeleted: boolean;55 readonly isDeleted: boolean;
apps/web/src/state/threads.ts+10−2
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
7 type EnvironmentThreadState,7 type EnvironmentThreadState,
8 createThreadEnvironmentAtoms,8 createThreadEnvironmentAtoms,
9} from "@t3tools/client-runtime/state/threads";9} from "@t3tools/client-runtime/state/threads";
10import { presentThread, type EnvironmentThread } from "@t3tools/client-runtime/state/shell";
10import type { EnvironmentId, ThreadId } from "@t3tools/contracts";11import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
11import * as Option from "effect/Option";12import * as Option from "effect/Option";
12import { AsyncResult, Atom } from "effect/unstable/reactivity";13import { AsyncResult, Atom } from "effect/unstable/reactivity";
@@ -32,14 +33,21 @@ const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_ · 19 unmodified lines
32export function useEnvironmentThread(33export function useEnvironmentThread(
33 environmentId: EnvironmentId | null,34 environmentId: EnvironmentId | null,
34 threadId: ThreadId | null,35 threadId: ThreadId | null,
35): EnvironmentThreadState {36): Omit<EnvironmentThreadState, "data"> & { readonly data: Option.Option<EnvironmentThread> } {
36 const result = useAtomValue(37 const result = useAtomValue(
37 environmentId !== null && threadId !== null38 environmentId !== null && threadId !== null
38 ? environmentThreads.stateAtom(environmentId, threadId)39 ? environmentThreads.stateAtom(environmentId, threadId)
39 : EMPTY_THREAD_STATE_ATOM,40 : EMPTY_THREAD_STATE_ATOM,
40 );41 );
41 return Option.getOrElse(42 const state = Option.getOrElse(
42 AsyncResult.value(result),43 AsyncResult.value(result),
43 () => EMPTY_ENVIRONMENT_THREAD_STATE,44 () => EMPTY_ENVIRONMENT_THREAD_STATE,
44 ) as EnvironmentThreadState;45 ) as EnvironmentThreadState;
46 return {
47 ...state,
48 data:
49 environmentId === null
50 ? Option.none()
51 : Option.map(state.data, (projection) => presentThread(environmentId, projection)),
52 };
45}53}
apps/web/src/test-fixtures.ts+89−0
@@ -0,0 +1,89 @@
1import { presentThread } from "@t3tools/client-runtime/state/shell";
2import {
3 EnvironmentId,
4 ProjectId,
5 ProviderInstanceId,
6 ThreadId,
7 type OrchestrationV2ThreadProjection,
8} from "@t3tools/contracts";
9import * as DateTime from "effect/DateTime";
10
11import type { Thread } from "./types";
12
13const DEFAULT_TIMESTAMP = "2026-01-01T00:00:00.000Z";
14
15/** Creates a structurally complete V2 thread for UI unit tests. */
16export 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 @@
1import type {1import type {
2 ChatImageAttachment as ContractChatImageAttachment,2 ChatImageAttachment as ContractChatImageAttachment,
3 OrchestrationCheckpointFile,
4 OrchestrationCheckpointSummary,
5 OrchestrationLatestTurn,
6 OrchestrationMessage,
7 OrchestrationProposedPlan,
8 OrchestrationSession,
9 ProjectScript as ContractProjectScript,3 ProjectScript as ContractProjectScript,
10 ProviderInteractionMode,4 ProviderInteractionMode,
11 RuntimeMode,5 RuntimeMode,
@@ -14,6 +8,11 @@ import type { · 2 unmodified lines
14 EnvironmentProject,8 EnvironmentProject,
15 EnvironmentThread,9 EnvironmentThread,
16 EnvironmentThreadShell,10 EnvironmentThreadShell,
11 ThreadCheckpointSummary,
12 ThreadConversationMessage,
13 ThreadProposedPlan,
14 ThreadRunSummary,
15 ThreadRuntimeSummary,
17} from "@t3tools/client-runtime/state/shell";16} from "@t3tools/client-runtime/state/shell";
18 17
19export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";18export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
@@ -37,21 +36,21 @@ export interface ChatImageAttachment extends ContractChatImageAttachment { · 17 unmodified lines
37 36
38export type ChatAttachment = ChatImageAttachment;37export type ChatAttachment = ChatImageAttachment;
39 38
40export interface ChatMessage extends Omit<OrchestrationMessage, "attachments"> {39export interface ChatMessage extends Omit<ThreadConversationMessage, "attachments"> {
41 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;40 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
42}41}
43 42
44export type ProposedPlan = OrchestrationProposedPlan;43export type ProposedPlan = ThreadProposedPlan;
45export type TurnDiffFileChange = OrchestrationCheckpointFile;44export type TurnDiffFileChange = ThreadCheckpointSummary["files"][number];
46export type TurnDiffSummary = OrchestrationCheckpointSummary;45export type TurnDiffSummary = ThreadCheckpointSummary;
47 46
48export type Project = EnvironmentProject;47export type Project = EnvironmentProject;
49export type Thread = EnvironmentThread;48export type Thread = EnvironmentThread;
50export type ThreadShell = EnvironmentThreadShell;49export type ThreadShell = EnvironmentThreadShell;
51 50
52export interface ThreadTurnState {51export interface ThreadTurnState {
53 latestTurn: OrchestrationLatestTurn | null;52 latestRun: ThreadRunSummary | null;
54}53}
55 54
56export type SidebarThreadSummary = EnvironmentThreadShell;55export type SidebarThreadSummary = EnvironmentThreadShell;
57export type ThreadSession = OrchestrationSession;56export 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
2import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
3 3
4import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types";4import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types";
5import { makeThreadFixture } from "./test-fixtures";
5import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup";6import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup";
6 7
7const localEnvironmentId = EnvironmentId.make("environment-local");8const localEnvironmentId = EnvironmentId.make("environment-local");
8 9
9function makeThread(overrides: Partial<Thread> = {}): Thread {10function makeThread(overrides: Partial<Thread> = {}): Thread {
10 return {11 return makeThreadFixture({
11 id: ThreadId.make("thread-1"),12 id: ThreadId.make("thread-1"),
12 environmentId: localEnvironmentId,13 environmentId: localEnvironmentId,
13 projectId: ProjectId.make("project-1"),14 projectId: ProjectId.make("project-1"),
@@ -18,10 +19,8 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 4 unmodified lines
18 },19 },
19 runtimeMode: DEFAULT_RUNTIME_MODE,20 runtimeMode: DEFAULT_RUNTIME_MODE,
20 interactionMode: DEFAULT_INTERACTION_MODE,21 interactionMode: DEFAULT_INTERACTION_MODE,
21 session: null,22 runtime: null,
22 messages: [],23 messages: [],
23 checkpoints: [],
24 activities: [],
25 proposedPlans: [],24 proposedPlans: [],
26 createdAt: "2026-02-13T00:00:00.000Z",25 createdAt: "2026-02-13T00:00:00.000Z",
27 updatedAt: "2026-02-13T00:00:00.000Z",26 updatedAt: "2026-02-13T00:00:00.000Z",
@@ -29,11 +28,11 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 1 unmodified line
29 settledOverride: null,28 settledOverride: null,
30 settledAt: null,29 settledAt: null,
31 deletedAt: null,30 deletedAt: null,
32 latestTurn: null,31 latestRun: null,
33 branch: null,32 branch: null,
34 worktreePath: null,33 worktreePath: null,
35 ...overrides,34 ...overrides,
36 };35 });
37}36}
38 37
39describe("getOrphanedWorktreePathForThread", () => {38describe("getOrphanedWorktreePathForThread", () => {
packages/client-runtime/src/connection/registry.test.ts+4−5
@@ -1,7 +1,7 @@
1import {1import {
2 type DesktopSshEnvironmentTarget,2 type DesktopSshEnvironmentTarget,
3 EnvironmentId,3 EnvironmentId,
4 type OrchestrationShellSnapshot,4 type OrchestrationV2ShellSnapshot,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import { describe, expect, it } from "@effect/vitest";6import { describe, expect, it } from "@effect/vitest";
7import * as Deferred from "effect/Deferred";7import * as Deferred from "effect/Deferred";
@@ -46,6 +46,7 @@ import * as EnvironmentRegistry from "./registry.ts"; · 38 unmodified lines
46import * as RpcSession from "../rpc/session.ts";46import * as RpcSession from "../rpc/session.ts";
47import * as EnvironmentSupervisor from "./supervisor.ts";47import * as EnvironmentSupervisor from "./supervisor.ts";
48import * as ConnectionWakeups from "./wakeups.ts";48import * as ConnectionWakeups from "./wakeups.ts";
49import { v2ShellSnapshot } from "../state/orchestrationV2TestFixtures.ts";
49 50
50const TARGET = new PrimaryConnectionTarget({51const TARGET = new PrimaryConnectionTarget({
51 environmentId: EnvironmentId.make("environment-1"),52 environmentId: EnvironmentId.make("environment-1"),
@@ -112,11 +113,9 @@ const SSH_PROFILE = new SshConnectionProfile({ · 60 unmodified lines
112 target: SSH_TARGET,113 target: SSH_TARGET,
113});114});
114 115
115const CACHED_SNAPSHOT: OrchestrationShellSnapshot = {116const CACHED_SNAPSHOT: OrchestrationV2ShellSnapshot = {
117 ...v2ShellSnapshot,
116 snapshotSequence: 1,118 snapshotSequence: 1,
117 projects: [],
118 threads: [],
119 updatedAt: "2026-06-06T00:00:00.000Z",
120};119};
121 120
122interface SessionControl {121interface SessionControl {
packages/client-runtime/src/operations/commands.test.ts+225−71
@@ -1,13 +1,22 @@
1import {1import {
2 CommandId,2 CommandId,
3 EnvironmentId,3 EnvironmentId,
4 ORCHESTRATION_WS_METHODS,4 MessageId,
5 ORCHESTRATION_V2_WS_METHODS,
6 PlanId,
5 ProjectId,7 ProjectId,
8 ProviderInstanceId,
9 RunId,
6 ThreadId,10 ThreadId,
7 type ClientOrchestrationCommand,11 WS_METHODS,
12 type OrchestrationV2Command,
13 type OrchestrationV2ThreadLaunchInput,
14 type OrchestrationV2ThreadProjection,
15 type ProjectMutation,
8} from "@t3tools/contracts";16} from "@t3tools/contracts";
9import { describe, expect, it } from "@effect/vitest";17import { describe, expect, it } from "@effect/vitest";
10import * as Crypto from "effect/Crypto";18import * as Crypto from "effect/Crypto";
19import * as DateTime from "effect/DateTime";
11import * as Effect from "effect/Effect";20import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";21import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";22import * as Option from "effect/Option";
@@ -21,13 +30,8 @@ import { · 7 unmodified lines
21import * as EnvironmentSupervisor from "../connection/supervisor.ts";30import * as EnvironmentSupervisor from "../connection/supervisor.ts";
22import * as RpcSession from "../rpc/session.ts";31import * as RpcSession from "../rpc/session.ts";
23import type { WsRpcProtocolClient } from "../rpc/protocol.ts";32import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
24import {33import { v2Projection, v2ThreadId } from "../state/orchestrationV2TestFixtures.ts";
25 archiveThread,34import { archiveThread, createProject, startThreadTurn } from "./commands.ts";
26 createProject,
27 settleThread,
28 stopThreadSession,
29 unsettleThread,
30} from "./commands.ts";
31 35
32const TEST_CRYPTO_LAYER = Layer.succeed(36const TEST_CRYPTO_LAYER = Layer.succeed(
33 Crypto.Crypto,37 Crypto.Crypto,
@@ -44,14 +48,45 @@ const TARGET = new PrimaryConnectionTarget({ · 10 unmodified lines
44 wsBaseUrl: "wss://environment.example.test",48 wsBaseUrl: "wss://environment.example.test",
45});49});
46 50
47const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(function* (51const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(function* (input: {
48 dispatched: ClientOrchestrationCommand[],52 readonly commands: OrchestrationV2Command[];
49) {53 readonly projects: ProjectMutation[];
54 readonly launches?: OrchestrationV2ThreadLaunchInput[];
55 readonly projection?: OrchestrationV2ThreadProjection;
56}) {
50 const client = {57 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) =>
52 Effect.sync(() => {66 Effect.sync(() => {
53 dispatched.push(command);67 input.launches?.push(launchInput);
54 return { sequence: dispatched.length };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 };
55 }),90 }),
56 } as unknown as WsRpcProtocolClient;91 } as unknown as WsRpcProtocolClient;
57 const session: RpcSession.RpcSession = {92 const session: RpcSession.RpcSession = {
@@ -72,103 +107,222 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct · 14 unmodified lines
72 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);107 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
73});108});
74 109
75describe("environment commands", () => {110describe("V2 environment commands", () => {
76 it.effect("adds generated command metadata", () =>111 it.effect("routes projects through the event-sourced project transport", () =>
77 Effect.gen(function* () {112 Effect.gen(function* () {
78 const dispatched: ClientOrchestrationCommand[] = [];113 const projects: ProjectMutation[] = [];
79 const supervisor = yield* makeSupervisor(dispatched);114 const supervisor = yield* makeSupervisor({ commands: [], projects });
80 115
81 const result = yield* createProject({116 yield* createProject({
82 projectId: ProjectId.make("project-1"),117 projectId: ProjectId.make("project-1"),
83 title: "Project",118 title: "Project",
84 workspaceRoot: "/workspace/project",119 workspaceRoot: "/workspace/project",
85 createdAt: "2026-06-06T00:00:00.000Z",120 createWorkspaceRootIfMissing: true,
86 }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));121 }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
87 122
88 expect(result).toEqual({ sequence: 1 });123 expect(projects).toEqual([
89 expect(dispatched).toEqual([
90 {124 {
91 type: "project.create",125 type: "project.create",
92 commandId: "00000000-0000-4000-8000-000000000000",126 commandId: "00000000-0000-4000-8000-000000000000",
93 projectId: "project-1",127 projectId: "project-1",
94 title: "Project",128 title: "Project",
95 workspaceRoot: "/workspace/project",129 workspaceRoot: "/workspace/project",
96 createdAt: "2026-06-06T00:00:00.000Z",130 createWorkspaceRootIfMissing: true,
97 },131 },
98 ]);132 ]);
99 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),133 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
100 );134 );
101 135
102 it.effect("preserves caller metadata for idempotent queued commands", () =>136 it.effect("preserves caller command ids for idempotent V2 commands", () =>
103 Effect.gen(function* () {137 Effect.gen(function* () {
104 const dispatched: ClientOrchestrationCommand[] = [];138 const commands: OrchestrationV2Command[] = [];
105 const supervisor = yield* makeSupervisor(dispatched);139 const supervisor = yield* makeSupervisor({ commands, projects: [] });
106 140
107 yield* stopThreadSession({141 yield* archiveThread({
108 commandId: CommandId.make("queued-command"),142 commandId: CommandId.make("queued-command"),
109 threadId: ThreadId.make("thread-1"),143 threadId: ThreadId.make("thread-1"),
110 createdAt: "2026-06-06T00:01:00.000Z",
111 }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));144 }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
112 145
113 expect(dispatched).toEqual([146 expect(commands).toEqual([
114 {147 { type: "thread.archive", commandId: "queued-command", threadId: "thread-1" },
115 type: "thread.session.stop",
116 commandId: "queued-command",
117 threadId: "thread-1",
118 createdAt: "2026-06-06T00:01:00.000Z",
119 },
120 ]);148 ]);
121 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),149 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
122 );150 );
123 151
124 it.effect("does not add timestamps to commands without createdAt", () =>152 it.effect("preserves plan implementation provenance on V2 runs", () =>
125 Effect.gen(function* () {153 Effect.gen(function* () {
126 const dispatched: ClientOrchestrationCommand[] = [];154 const commands: OrchestrationV2Command[] = [];
127 const supervisor = yield* makeSupervisor(dispatched);155 const supervisor = yield* makeSupervisor({ commands, projects: [] });
128 156
129 yield* archiveThread({157 yield* startThreadTurn({
130 commandId: CommandId.make("archive-command"),158 commandId: CommandId.make("implement-plan"),
131 threadId: ThreadId.make("thread-1"),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 },
132 }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));176 }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
133 177
134 expect(dispatched).toEqual([178 expect(commands).toHaveLength(1);
135 {179 expect(commands[0]).toMatchObject({
136 type: "thread.archive",180 type: "message.dispatch",
137 commandId: "archive-command",181 commandId: "implement-plan",
138 threadId: "thread-1",182 threadId: v2ThreadId,
139 },183 sourcePlanRef: { threadId: "thread-plan", planId: "plan-1" },
140 ]);184 dispatchMode: { type: "start_immediately" },
185 });
141 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),186 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
142 );187 );
143 188
144 it.effect("dispatches settle and unsettle commands without timestamps", () =>189 it.effect("preserves an existing worktree and branch during first-message launch", () =>
145 Effect.gen(function* () {190 Effect.gen(function* () {
146 const dispatched: ClientOrchestrationCommand[] = [];191 const launches: OrchestrationV2ThreadLaunchInput[] = [];
147 const supervisor = yield* makeSupervisor(dispatched);192 const supervisor = yield* makeSupervisor({ commands: [], projects: [], launches });
148 193
149 yield* settleThread({194 yield* startThreadTurn({
150 commandId: CommandId.make("settle-command"),195 commandId: CommandId.make("launch-existing-worktree"),
151 threadId: ThreadId.make("thread-1"),196 threadId: v2ThreadId,
152 }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));197 message: {
153 yield* unsettleThread({198 messageId: MessageId.make("message-existing-worktree"),
154 commandId: CommandId.make("unsettle-command"),199 role: "user",
155 threadId: ThreadId.make("thread-1"),200 text: "Continue here",
156 reason: "user",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 },
157 }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));217 }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
158 218
159 expect(dispatched).toEqual([219 expect(launches[0]).toMatchObject({
160 {220 threadId: v2ThreadId,
161 type: "thread.settle",221 workspaceStrategy: {
162 commandId: "settle-command",222 type: "existing_worktree",
163 threadId: "thread-1",223 worktreePath: "/workspace/project-worktrees/feature",
224 branch: "feature",
164 },225 },
165 {226 });
166 type: "thread.unsettle",227 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
167 commandId: "unsettle-command",228 );
168 threadId: "thread-1",229
169 reason: "user",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: [],
170 },243 },
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 }
172 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),326 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
173 );327 );
174});328});
packages/client-runtime/src/operations/commands.ts+484−236
@@ -1,300 +1,548 @@
1import {1import {
2 CommandId,2 CommandId,
3 ORCHESTRATION_WS_METHODS,3 ORCHESTRATION_V2_WS_METHODS,
4 type ClientOrchestrationCommand,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,
5} from "@t3tools/contracts";21} from "@t3tools/contracts";
6import * as Crypto from "effect/Crypto";22import * as Crypto from "effect/Crypto";
7import * as DateTime from "effect/DateTime";
8import * as Effect from "effect/Effect";23import * as Effect from "effect/Effect";
9 24
10import type { EnvironmentSupervisor } from "../connection/supervisor.ts";25import { request } from "../rpc/client.ts";
11import {
12 type EnvironmentRpcFailure,
13 type EnvironmentRpcSuccess,
14 type EnvironmentRpcUnavailableError,
15 request,
16} from "../rpc/client.ts";
17
18type CommandType = ClientOrchestrationCommand["type"];
19type CommandOf<T extends CommandType> = Extract<ClientOrchestrationCommand, { readonly type: T }>;
20type 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
31export type CreateProjectInput = CommandInput<"project.create">;
32export type UpdateProjectInput = CommandInput<"project.meta.update">;
33export type DeleteProjectInput = CommandInput<"project.delete">;
34export type CreateThreadInput = CommandInput<"thread.create">;
35export type DeleteThreadInput = CommandInput<"thread.delete">;
36export type ArchiveThreadInput = CommandInput<"thread.archive">;
37export type UnarchiveThreadInput = CommandInput<"thread.unarchive">;
38export type SettleThreadInput = CommandInput<"thread.settle">;
39export type UnsettleThreadInput = CommandInput<"thread.unsettle">;
40export type SnoozeThreadInput = CommandInput<"thread.snooze">;
41export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">;
42export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">;
43export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">;
44export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">;
45export type StartThreadTurnInput = CommandInput<"thread.turn.start">;
46export type InterruptThreadTurnInput = CommandInput<"thread.turn.interrupt">;
47export type RespondToThreadApprovalInput = CommandInput<"thread.approval.respond">;
48export type RespondToThreadUserInputInput = CommandInput<"thread.user-input.respond">;
49export type RevertThreadCheckpointInput = CommandInput<"thread.checkpoint.revert">;
50export type StopThreadSessionInput = CommandInput<"thread.session.stop">;
51
52type DispatchTag = typeof ORCHESTRATION_WS_METHODS.dispatchCommand;
53type CommandEffect = Effect.Effect<
54 EnvironmentRpcSuccess<DispatchTag>,
55 EnvironmentRpcFailure<DispatchTag> | EnvironmentRpcUnavailableError,
56 Crypto.Crypto | EnvironmentSupervisor
57>;
58
59function 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}
68 26
69function timestampedCommandMetadata(input: {27interface CommandMetadata {
70 readonly commandId?: CommandId;28 readonly commandId?: CommandId;
71 readonly createdAt?: string;29 readonly createdAt?: string;
72}) {30 readonly creationSource?: OrchestrationV2CreationSource;
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 });
80}31}
81 32
82function dispatch(command: ClientOrchestrationCommand) {33export interface CreateProjectInput extends CommandMetadata {
83 return request(ORCHESTRATION_WS_METHODS.dispatchCommand, command);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>;
84}40}
85 41
86export const createProject: (input: CreateProjectInput) => CommandEffect = Effect.fn(42export interface UpdateProjectInput extends CommandMetadata {
87 "EnvironmentCommands.createProject",43 readonly projectId: ProjectId;
88)(function* (input) {44 readonly title?: string;
89 const metadata = yield* timestampedCommandMetadata(input);45 readonly workspaceRoot?: string;
90 return yield* dispatch({46 readonly defaultModelSelection?: ModelSelection | null;
91 ...input,47 readonly scripts?: ReadonlyArray<ProjectScript>;
92 type: "project.create",48}
93 commandId: metadata.commandId,
94 createdAt: metadata.createdAt,
95 });
96});
97 49
98export const updateProject: (input: UpdateProjectInput) => CommandEffect = Effect.fn(50export interface DeleteProjectInput extends CommandMetadata {
99 "EnvironmentCommands.updateProject",51 readonly projectId: ProjectId;
100)(function* (input) {52 readonly force?: boolean;
101 return yield* dispatch({53}
102 ...input,
103 type: "project.meta.update",
104 commandId: yield* commandId(input),
105 });
106});
107 54
108export const deleteProject: (input: DeleteProjectInput) => CommandEffect = Effect.fn(55export interface CreateThreadInput extends CommandMetadata {
109 "EnvironmentCommands.deleteProject",56 readonly threadId: ThreadId;
110)(function* (input) {57 readonly projectId: ProjectId;
111 return yield* dispatch({58 readonly title: string;
112 ...input,59 readonly modelSelection: ModelSelection;
113 type: "project.delete",60 readonly runtimeMode: RuntimeMode;
114 commandId: yield* commandId(input),61 readonly interactionMode: ProviderInteractionMode;
115 });62 readonly branch: string | null;
116});63 readonly worktreePath: string | null;
64}
117 65
118export const createThread: (input: CreateThreadInput) => CommandEffect = Effect.fn(66export interface ThreadCommandInput extends CommandMetadata {
119 "EnvironmentCommands.createThread",67 readonly threadId: ThreadId;
120)(function* (input) {68}
121 const metadata = yield* timestampedCommandMetadata(input);69
122 return yield* dispatch({70export type DeleteThreadInput = ThreadCommandInput;
123 ...input,71export type ArchiveThreadInput = ThreadCommandInput;
124 type: "thread.create",72export type UnarchiveThreadInput = ThreadCommandInput;
125 commandId: metadata.commandId,73
126 createdAt: metadata.createdAt,74export interface UpdateThreadMetadataInput extends ThreadCommandInput {
127 });75 readonly title?: string;
76 readonly modelSelection?: ModelSelection;
77 readonly branch?: string | null;
78 readonly worktreePath?: string | null;
79}
80
81export interface SetThreadRuntimeModeInput extends ThreadCommandInput {
82 readonly runtimeMode: RuntimeMode;
83}
84
85export interface SetThreadInteractionModeInput extends ThreadCommandInput {
86 readonly interactionMode: ProviderInteractionMode;
87}
88
89interface 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
109export 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
125export 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
131export interface RespondToThreadApprovalInput extends ThreadCommandInput {
132 readonly requestId: RuntimeRequestId;
133 readonly decision: ProviderApprovalDecision;
134}
135
136export interface RespondToThreadUserInputInput extends ThreadCommandInput {
137 readonly requestId: RuntimeRequestId;
138 readonly answers: ProviderUserInputAnswers;
139}
140
141export interface RevertThreadCheckpointInput extends ThreadCommandInput {
142 readonly checkpointId?: string;
143 readonly scopeId?: string;
144 readonly turnCount?: number;
145}
146
147export type StopThreadSessionInput = ThreadCommandInput;
148
149const 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));
128});155});
129 156
130export const deleteThread: (input: DeleteThreadInput) => CommandEffect = Effect.fn(157const dispatch = (command: OrchestrationV2Command) =>
131 "EnvironmentCommands.deleteThread",158 request(ORCHESTRATION_V2_WS_METHODS.dispatchCommand, command);
132)(function* (input) {159
133 return yield* dispatch({160const getProjection = (threadId: ThreadId) =>
134 ...input,161 request(ORCHESTRATION_V2_WS_METHODS.getThreadProjection, { threadId });
135 type: "thread.delete",162
136 commandId: yield* commandId(input),163const 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];
137 });188 });
138});189});
139 190
140export const archiveThread: (input: ArchiveThreadInput) => CommandEffect = Effect.fn(191const mutateProject = Effect.fn("EnvironmentCommands.mutateProject")(function* (
141 "EnvironmentCommands.archiveThread",192 mutation:
142)(function* (input) {193 | {
143 return yield* dispatch({194 readonly type: "project.create";
144 ...input,195 readonly commandId: CommandId;
145 type: "thread.archive",196 readonly projectId: ProjectId;
146 commandId: yield* commandId(input),197 readonly title: string;
147 });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);
148});220});
149 221
150export const unarchiveThread: (input: UnarchiveThreadInput) => CommandEffect = Effect.fn(222export const createProject = Effect.fn("EnvironmentCommands.createProject")(function* (
151 "EnvironmentCommands.unarchiveThread",223 input: CreateProjectInput,
152)(function* (input) {224) {
153 return yield* dispatch({225 return yield* mutateProject({
154 ...input,226 type: "project.create",
155 type: "thread.unarchive",227 commandId: yield* allocateCommandId(input),
156 commandId: yield* commandId(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 }),
157 });238 });
158});239});
159 240
160export const settleThread: (input: SettleThreadInput) => CommandEffect = Effect.fn(241export const updateProject = Effect.fn("EnvironmentCommands.updateProject")(function* (
161 "EnvironmentCommands.settleThread",242 input: UpdateProjectInput,
162)(function* (input) {243) {
163 return yield* dispatch({244 return yield* mutateProject({
164 ...input,245 type: "project.update",
165 type: "thread.settle",246 commandId: yield* allocateCommandId(input),
166 commandId: yield* commandId(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 }),
167 });254 });
168});255});
169 256
170export const unsettleThread: (input: UnsettleThreadInput) => CommandEffect = Effect.fn(257export const deleteProject = Effect.fn("EnvironmentCommands.deleteProject")(function* (
171 "EnvironmentCommands.unsettleThread",258 input: DeleteProjectInput,
172)(function* (input) {259) {
173 return yield* dispatch({260 return yield* mutateProject({
174 ...input,261 type: "project.delete",
175 type: "thread.unsettle",262 commandId: yield* allocateCommandId(input),
176 commandId: yield* commandId(input),263 projectId: input.projectId,
264 ...(input.force === undefined ? {} : { force: input.force }),
177 });265 });
178});266});
179 267
180export const snoozeThread: (input: SnoozeThreadInput) => CommandEffect = Effect.fn(268export const createThread = Effect.fn("EnvironmentCommands.createThread")(function* (
181 "EnvironmentCommands.snoozeThread",269 input: CreateThreadInput,
182)(function* (input) {270) {
183 return yield* dispatch({271 return yield* dispatch({
184 ...input,272 type: "thread.create",
185 type: "thread.snooze",273 commandId: yield* allocateCommandId(input),
186 commandId: yield* commandId(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,
187 });284 });
188});285});
189 286
190export const unsnoozeThread: (input: UnsnoozeThreadInput) => CommandEffect = Effect.fn(287function simpleThreadCommand(
191 "EnvironmentCommands.unsnoozeThread",288 type: "thread.delete" | "thread.archive" | "thread.unarchive",
192)(function* (input) {289 input: ThreadCommandInput,
193 return yield* dispatch({290) {
194 ...input,291 return allocateCommandId(input).pipe(
195 type: "thread.unsnooze",292 Effect.flatMap((commandId) => dispatch({ type, commandId, threadId: input.threadId })),
196 commandId: yield* commandId(input),293 );
197 });294}
295
296export const deleteThread = Effect.fn("EnvironmentCommands.deleteThread")(function* (
297 input: DeleteThreadInput,
298) {
299 return yield* simpleThreadCommand("thread.delete", input);
198});300});
199 301
200export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn(302export const archiveThread = Effect.fn("EnvironmentCommands.archiveThread")(function* (
201 "EnvironmentCommands.updateThreadMetadata",303 input: ArchiveThreadInput,
202)(function* (input) {304) {
203 return yield* dispatch({305 return yield* simpleThreadCommand("thread.archive", input);
204 ...input,
205 type: "thread.meta.update",
206 commandId: yield* commandId(input),
207 });
208});306});
209 307
210export const setThreadRuntimeMode: (input: SetThreadRuntimeModeInput) => CommandEffect = Effect.fn(308export const unarchiveThread = Effect.fn("EnvironmentCommands.unarchiveThread")(function* (
211 "EnvironmentCommands.setThreadRuntimeMode",309 input: UnarchiveThreadInput,
212)(function* (input) {310) {
213 const metadata = yield* timestampedCommandMetadata(input);311 return yield* simpleThreadCommand("thread.unarchive", input);
214 return yield* dispatch({
215 ...input,
216 type: "thread.runtime-mode.set",
217 commandId: metadata.commandId,
218 createdAt: metadata.createdAt,
219 });
220});312});
221 313
222export const setThreadInteractionMode: (input: SetThreadInteractionModeInput) => CommandEffect =314export const updateThreadMetadata = Effect.fn("EnvironmentCommands.updateThreadMetadata")(
223 Effect.fn("EnvironmentCommands.setThreadInteractionMode")(function* (input) {315 function* (input: UpdateThreadMetadataInput) {
224 const metadata = yield* timestampedCommandMetadata(input);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
344export 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
355export const setThreadInteractionMode = Effect.fn("EnvironmentCommands.setThreadInteractionMode")(
356 function* (input: SetThreadInteractionModeInput) {
225 return yield* dispatch({357 return yield* dispatch({
226 ...input,
227 type: "thread.interaction-mode.set",358 type: "thread.interaction-mode.set",
228 commandId: metadata.commandId,359 commandId: yield* allocateCommandId(input),
229 createdAt: metadata.createdAt,360 threadId: input.threadId,
361 interactionMode: input.interactionMode,
230 });362 });
231 });363 },
364);
232 365
233export const startThreadTurn: (input: StartThreadTurnInput) => CommandEffect = Effect.fn(366export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")(function* (
234 "EnvironmentCommands.startThreadTurn",367 input: StartThreadTurnInput,
235)(function* (input) {368) {
236 const metadata = yield* timestampedCommandMetadata(input);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);
237 return yield* dispatch({455 return yield* dispatch({
238 ...input,456 type: "message.dispatch",
239 type: "thread.turn.start",457 commandId,
240 commandId: metadata.commandId,458 createdBy: "user",
241 createdAt: metadata.createdAt,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,
242 });467 });
243});468});
244 469
245export const interruptThreadTurn: (input: InterruptThreadTurnInput) => CommandEffect = Effect.fn(470export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThreadTurn")(function* (
246 "EnvironmentCommands.interruptThreadTurn",471 input: InterruptThreadTurnInput,
247)(function* (input) {472) {
248 const metadata = yield* timestampedCommandMetadata(input);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 };
249 return yield* dispatch({481 return yield* dispatch({
250 ...input,482 type: "run.interrupt",
251 type: "thread.turn.interrupt",483 commandId: yield* allocateCommandId(input),
252 commandId: metadata.commandId,484 threadId: input.threadId,
253 createdAt: metadata.createdAt,485 runId,
254 });486 });
255});487});
256 488
257export const respondToThreadApproval: (input: RespondToThreadApprovalInput) => CommandEffect =489export const respondToThreadApproval = Effect.fn("EnvironmentCommands.respondToThreadApproval")(
258 Effect.fn("EnvironmentCommands.respondToThreadApproval")(function* (input) {490 function* (input: RespondToThreadApprovalInput) {
259 const metadata = yield* timestampedCommandMetadata(input);
260 return yield* dispatch({491 return yield* dispatch({
261 ...input,492 type: "runtime-request.respond",
262 type: "thread.approval.respond",493 commandId: yield* allocateCommandId(input),
263 commandId: metadata.commandId,494 threadId: input.threadId,
264 createdAt: metadata.createdAt,495 requestId: input.requestId,
496 decision: input.decision,
265 });497 });
266 });498 },
499);
267 500
268export const respondToThreadUserInput: (input: RespondToThreadUserInputInput) => CommandEffect =501export const respondToThreadUserInput = Effect.fn("EnvironmentCommands.respondToThreadUserInput")(
269 Effect.fn("EnvironmentCommands.respondToThreadUserInput")(function* (input) {502 function* (input: RespondToThreadUserInputInput) {
270 const metadata = yield* timestampedCommandMetadata(input);
271 return yield* dispatch({503 return yield* dispatch({
272 ...input,504 type: "runtime-request.respond",
273 type: "thread.user-input.respond",505 commandId: yield* allocateCommandId(input),
274 commandId: metadata.commandId,506 threadId: input.threadId,
275 createdAt: metadata.createdAt,507 requestId: input.requestId,
508 answers: input.answers,
276 });509 });
277 });510 },
511);
278 512
279export const revertThreadCheckpoint: (input: RevertThreadCheckpointInput) => CommandEffect =513export const revertThreadCheckpoint = Effect.fn("EnvironmentCommands.revertThreadCheckpoint")(
280 Effect.fn("EnvironmentCommands.revertThreadCheckpoint")(function* (input) {514 function* (input: RevertThreadCheckpointInput) {
281 const metadata = yield* timestampedCommandMetadata(input);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 };
282 return yield* dispatch({522 return yield* dispatch({
283 ...input,523 type: "checkpoint.rollback",
284 type: "thread.checkpoint.revert",524 commandId: yield* allocateCommandId(input),
285 commandId: metadata.commandId,525 threadId: input.threadId,
286 createdAt: metadata.createdAt,526 scopeId: checkpoint.scopeId,
527 checkpointId: checkpoint.id,
287 });528 });
288 });529 },
530);
289 531
290export const stopThreadSession: (input: StopThreadSessionInput) => CommandEffect = Effect.fn(532export const stopThreadSession = Effect.fn("EnvironmentCommands.stopThreadSession")(function* (
291 "EnvironmentCommands.stopThreadSession",533 input: StopThreadSessionInput,
292)(function* (input) {534) {
293 const metadata = yield* timestampedCommandMetadata(input);535 const projection = yield* getProjection(input.threadId);
294 return yield* dispatch({536 const commandId = yield* allocateCommandId(input);
295 ...input,537 let result = { sequence: 0 };
296 type: "thread.session.stop",538 for (const session of projection.providerSessions) {
297 commandId: metadata.commandId,539 result = yield* dispatch({
298 createdAt: metadata.createdAt,540 type: "provider-session.detach",
299 });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;
300});548});
packages/client-runtime/src/platform/index.ts+1−0
@@ -1,4 +1,5 @@
1export * from "./capabilities.ts";1export * from "./capabilities.ts";
2export * from "./persistence.ts";2export * from "./persistence.ts";
3export * from "./orchestrationCache.ts";
3export * from "./source.ts";4export * from "./source.ts";
4export * from "./storageDocument.ts";5export * from "./storageDocument.ts";
packages/client-runtime/src/platform/orchestrationCache.test.ts+59−0
@@ -0,0 +1,59 @@
1import { describe, expect, it } from "@effect/vitest";
2import { EnvironmentId } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
6
7import {
8 ORCHESTRATION_CACHE_SCHEMA_VERSION,
9 StoredOrchestrationShellSnapshot,
10 StoredOrchestrationThreadSnapshot,
11 decodeOrDiscardOrchestrationCache,
12} from "./orchestrationCache.ts";
13import { v2Projection, v2ShellSnapshot, v2ThreadId } from "../state/orchestrationV2TestFixtures.ts";
14
15const environmentId = EnvironmentId.make("environment-cache-test");
16const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredOrchestrationShellSnapshot);
17const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredOrchestrationThreadSnapshot);
18
19describe("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 @@
1import {
2 EnvironmentId,
3 OrchestrationV2ShellSnapshot,
4 OrchestrationV2ThreadProjection,
5 ThreadId,
6} from "@t3tools/contracts";
7import * as Effect from "effect/Effect";
8import * as Option from "effect/Option";
9import * as Schema from "effect/Schema";
10
11export const ORCHESTRATION_CACHE_SCHEMA_VERSION = 2 as const;
12
13export const StoredOrchestrationShellSnapshot = Schema.Struct({
14 schemaVersion: Schema.Literal(ORCHESTRATION_CACHE_SCHEMA_VERSION),
15 environmentId: EnvironmentId,
16 snapshot: OrchestrationV2ShellSnapshot,
17});
18
19export 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. */
27export 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 @@
1import {1import {
2 type EnvironmentId,2 type EnvironmentId,
3 type OrchestrationShellSnapshot,3 type OrchestrationV2ShellSnapshot,
4 type OrchestrationThreadDetailSnapshot,4 type OrchestrationV2ThreadProjection,
5 type ServerConfig,
6 type ThreadId,5 type ThreadId,
7 type VcsListRefsResult,6 type VcsListRefsResult,
8} from "@t3tools/contracts";7} from "@t3tools/contracts";
@@ -60,21 +59,18 @@ export class EnvironmentCacheStore extends Context.Service< · 51 unmodified lines
60 {59 {
61 readonly loadShell: (60 readonly loadShell: (
62 environmentId: EnvironmentId,61 environmentId: EnvironmentId,
63 ) => Effect.Effect<Option.Option<OrchestrationShellSnapshot>, ConnectionPersistenceError>;62 ) => Effect.Effect<Option.Option<OrchestrationV2ShellSnapshot>, ConnectionPersistenceError>;
64 readonly saveShell: (63 readonly saveShell: (
65 environmentId: EnvironmentId,64 environmentId: EnvironmentId,
66 snapshot: OrchestrationShellSnapshot,65 snapshot: OrchestrationV2ShellSnapshot,
67 ) => Effect.Effect<void, ConnectionPersistenceError>;66 ) => Effect.Effect<void, ConnectionPersistenceError>;
68 readonly loadThread: (67 readonly loadThread: (
69 environmentId: EnvironmentId,68 environmentId: EnvironmentId,
70 threadId: ThreadId,69 threadId: ThreadId,
71 ) => Effect.Effect<70 ) => Effect.Effect<Option.Option<OrchestrationV2ThreadProjection>, ConnectionPersistenceError>;
72 Option.Option<OrchestrationThreadDetailSnapshot>,
73 ConnectionPersistenceError
74 >;
75 readonly saveThread: (71 readonly saveThread: (
76 environmentId: EnvironmentId,72 environmentId: EnvironmentId,
77 snapshot: OrchestrationThreadDetailSnapshot,73 thread: OrchestrationV2ThreadProjection,
78 ) => Effect.Effect<void, ConnectionPersistenceError>;74 ) => Effect.Effect<void, ConnectionPersistenceError>;
79 readonly removeThread: (75 readonly removeThread: (
80 environmentId: EnvironmentId,76 environmentId: EnvironmentId,
packages/client-runtime/src/rpc/client.ts+3−7
@@ -1,8 +1,4 @@
1import {1import { ORCHESTRATION_V2_WS_METHODS, WS_METHODS } from "@t3tools/contracts";
2 ORCHESTRATION_V2_WS_METHODS,
3 ORCHESTRATION_WS_METHODS,
4 WS_METHODS,
5} from "@t3tools/contracts";
6import * as Cause from "effect/Cause";2import * as Cause from "effect/Cause";
7import * as Context from "effect/Context";3import * as Context from "effect/Context";
8import type * as Duration from "effect/Duration";4import type * as Duration from "effect/Duration";
@@ -44,8 +40,8 @@ export type EnvironmentRpcTag = keyof WsRpcProtocolClient & string; · 35 unmodified lines
44type RpcMethod<TTag extends EnvironmentRpcTag> = WsRpcProtocolClient[TTag];40type RpcMethod<TTag extends EnvironmentRpcTag> = WsRpcProtocolClient[TTag];
45 41
46export type EnvironmentSubscriptionRpcTag =42export type EnvironmentSubscriptionRpcTag =
47 | typeof ORCHESTRATION_WS_METHODS.subscribeShell43 | typeof ORCHESTRATION_V2_WS_METHODS.subscribeShell
48 | typeof ORCHESTRATION_WS_METHODS.subscribeThread44 | typeof ORCHESTRATION_V2_WS_METHODS.subscribeThread
49 | typeof WS_METHODS.subscribeAuthAccess45 | typeof WS_METHODS.subscribeAuthAccess
50 | typeof WS_METHODS.subscribeServerConfig46 | typeof WS_METHODS.subscribeServerConfig
51 | typeof WS_METHODS.subscribeServerLifecycle47 | typeof WS_METHODS.subscribeServerLifecycle
packages/client-runtime/src/state/archivedThreads.test.ts+2−2
@@ -1,4 +1,4 @@
1import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts";1import { EnvironmentId, type OrchestrationV2ArchivedShellSnapshot } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";2import * as Cause from "effect/Cause";
3import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";3import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
4import { expect, it } from "vite-plus/test";4import { expect, it } from "vite-plus/test";
@@ -22,7 +22,7 @@ it("does not expose an archived snapshot failure message", () => { · 17 unmodified lines
22 const snapshotsAtom = createArchivedThreadSnapshotsAtomFamily<Error>({22 const snapshotsAtom = createArchivedThreadSnapshotsAtomFamily<Error>({
23 getSnapshotAtom: () =>23 getSnapshotAtom: () =>
24 Atom.make(24 Atom.make(
25 AsyncResult.failure<OrchestrationShellSnapshot, Error>(25 AsyncResult.failure<OrchestrationV2ArchivedShellSnapshot, Error>(
26 Cause.fail(new Error("credential=secret-value")),26 Cause.fail(new Error("credential=secret-value")),
27 ),27 ),
28 ),28 ),
packages/client-runtime/src/state/archivedThreads.ts+3−3
@@ -1,4 +1,4 @@
1import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts";1import { EnvironmentId, type OrchestrationV2ArchivedShellSnapshot } from "@t3tools/contracts";
2import * as Arr from "effect/Array";2import * as Arr from "effect/Array";
3import { pipe } from "effect/Function";3import { pipe } from "effect/Function";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
@@ -7,7 +7,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; · 2 unmodified lines
7 7
8export interface ArchivedSnapshotEntry {8export interface ArchivedSnapshotEntry {
9 readonly environmentId: EnvironmentId;9 readonly environmentId: EnvironmentId;
10 readonly snapshot: OrchestrationShellSnapshot;10 readonly snapshot: OrchestrationV2ArchivedShellSnapshot;
11}11}
12 12
13export interface ArchivedThreadSnapshotsState {13export interface ArchivedThreadSnapshotsState {
@@ -40,7 +40,7 @@ export function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray<E · 26 unmodified lines
40export function createArchivedThreadSnapshotsAtomFamily<E>(options: {40export function createArchivedThreadSnapshotsAtomFamily<E>(options: {
41 readonly getSnapshotAtom: (41 readonly getSnapshotAtom: (
42 environmentId: EnvironmentId,42 environmentId: EnvironmentId,
43 ) => Atom.Atom<AsyncResult.AsyncResult<OrchestrationShellSnapshot, E>>;43 ) => Atom.Atom<AsyncResult.AsyncResult<OrchestrationV2ArchivedShellSnapshot, E>>;
44 readonly labelPrefix: string;44 readonly labelPrefix: string;
45}) {45}) {
46 return Atom.family((environmentKey: string) =>46 return Atom.family((environmentKey: string) =>
packages/client-runtime/src/state/entities.test.ts+101−356
@@ -1,370 +1,115 @@
1import {1import { EnvironmentId, MessageId, RunId, TurnItemId } from "@t3tools/contracts";
2 EnvironmentId,2import * as DateTime from "effect/DateTime";
3 ProjectId,3import { describe, expect, it } from "vite-plus/test";
4 ProviderInstanceId,4
5 ThreadId,5import { presentThread, presentThreadShell } from "./models.ts";
6 type OrchestrationShellSnapshot,6import { v2Projection, v2ThreadShell } from "./orchestrationV2TestFixtures.ts";
7 type OrchestrationThread,7
8} from "@t3tools/contracts";8const environmentId = EnvironmentId.make("environment-v2");
9import { describe, expect, it } from "@effect/vitest";9
10import * as Option from "effect/Option";10describe("V2 client presentation", () => {
11import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";11 it("presents shell timestamps and status without constructing V1 state", () => {
12 12 const shell = presentThreadShell(environmentId, v2ThreadShell);
13import { PrimaryConnectionTarget } from "../connection/model.ts";13 expect(shell.environmentId).toBe(environmentId);
14import {14 expect(shell.createdAt).toBe("2026-06-20T00:00:00.000Z");
15 InvalidScopedProjectKeyError,15 expect(shell.runtime).toBeNull();
16 InvalidScopedProjectRefCollectionKeyError,16 expect(shell.source).toBe(v2ThreadShell);
17 InvalidScopedThreadKeyError,
18 parseProjectKey,
19 parseProjectRefCollectionKey,
20 parseThreadKey,
21} from "./entities.ts";
22import type { EnvironmentShellState } from "./shell.ts";
23import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts";
24import { createEnvironmentProjectAtoms } from "./projectEntities.ts";
25import { createEnvironmentSnapshotAtom } from "./snapshots.ts";
26import { createEnvironmentThreadDetailAtoms } from "./threadDetail.ts";
27import { mergeEnvironmentThread } from "./threadDetail.ts";
28import { createEnvironmentThreadShellAtoms } from "./threadShell.ts";
29
30const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
31const PROJECT_ID = ProjectId.make("project-1");
32const OTHER_PROJECT_ID = ProjectId.make("project-2");
33const THREAD_ID = ThreadId.make("thread-1");
34const OTHER_THREAD_ID = ThreadId.make("thread-2");
35
36describe("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() });
75 });17 });
76 18
77 it("rejects invalid project reference collection shapes", () => {19 it("retains the complete projection while deriving conversation parity", () => {
78 const key = JSON.stringify([["environment-1"]]);20 const runId = RunId.make("run-1");
79 21 const now = DateTime.makeUnsafe("2026-06-20T01:00:00.000Z");
80 expect(() => parseProjectRefCollectionKey(key)).toThrowError(22 const projection = {
81 InvalidScopedProjectRefCollectionKeyError,23 ...v2Projection,
82 );24 runs: [
83 });
84});
85
86const 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
108const 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
144function shellState(snapshot: OrchestrationShellSnapshot): EnvironmentShellState {
145 return {
146 snapshot: Option.some(snapshot),
147 status: "live",
148 error: Option.none(),
149 };
150}
151
152function 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,
164 {25 {
165 target: new PrimaryConnectionTarget({26 id: runId,
166 environmentId: ENVIRONMENT_ID,27 threadId: v2Projection.thread.id,
167 label: "Environment",28 ordinal: 1,
168 httpBaseUrl: "https://example.test",29 providerInstanceId: v2Projection.thread.providerInstanceId,
169 wsBaseUrl: "wss://example.test",30 modelSelection: v2Projection.thread.modelSelection,
170 }),31 providerThreadId: null,
171 profile: Option.none(),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,
172 },41 },
173 ],42 ],
174 ]),43 messages: [
175 });44 {
176 const snapshotAtom = createEnvironmentSnapshotAtom(shellStateAtoms);45 id: MessageId.make("message-user"),
177 const projects = createEnvironmentProjectAtoms({46 threadId: v2Projection.thread.id,
178 catalogValueAtom,47 runId,
179 snapshotAtom,48 nodeId: null,
180 });49 role: "user" as const,
181 const threadShells = createEnvironmentThreadShellAtoms({50 text: "Hello",
182 catalogValueAtom,51 attachments: [],
183 snapshotAtom,52 streaming: false,
184 });53 createdBy: "user" as const,
185 const threadDetails = createEnvironmentThreadDetailAtoms((environmentId, threadId) =>54 creationSource: "web" as const,
186 threadStateAtoms(`${environmentId}\u0000${threadId}`),55 createdAt: now,
187 );56 updatedAt: now,
188 57 },
189 return {58 ],
190 registry: AtomRegistry.make(),59 updatedAt: now,
191 shellStateAtom: shellStateAtoms(ENVIRONMENT_ID),
192 threadStateAtom: (threadId: ThreadId) => threadStateAtoms(`${ENVIRONMENT_ID}\u0000${threadId}`),
193 projects,
194 threadShells,
195 threadDetails,
196 };
197}
198
199describe("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,
278 };60 };
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);
287 61
288 harness.registry.set(62 const thread = presentThread(environmentId, projection);
289 harness.shellStateAtom,63 expect(thread.projection).toBe(projection);
290 AsyncResult.success(64 expect(thread.latestRun?.runId).toBe(runId);
291 shellState({65 expect(thread.runtime?.status).toBe("running");
292 ...SNAPSHOT,66 expect(thread.runs).toHaveLength(1);
293 snapshotSequence: 2,67 expect(thread.availableActions.canInterrupt).toBe(true);
294 threads: SNAPSHOT.threads.map((thread) =>68 expect(thread.messages[0]).toMatchObject({ text: "Hello", runId });
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);
303 });69 });
304 70
305 it("updates only the requested thread detail and preserves untouched field identities", () => {71 it("keeps the complete payload for generic V2 work-item rendering", () => {
306 const harness = makeHarness();72 const now = DateTime.makeUnsafe("2026-06-20T01:00:00.000Z");
307 const threadRef = {73 const item = {
308 environmentId: ENVIRONMENT_ID,74 id: TurnItemId.make("item-dynamic"),
309 threadId: THREAD_ID,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 },
310 };92 };
311 const otherThreadRef = {93 const projection = {
312 environmentId: ENVIRONMENT_ID,94 ...v2Projection,
313 threadId: OTHER_THREAD_ID,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,
314 };106 };
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);
342 107
343 expect(scopedDetail).toEqual({ ...detail, environmentId: ENVIRONMENT_ID });108 const thread = presentThread(environmentId, projection);
344 expect(harness.registry.get(statusAtom)).toBe("live");109 expect(thread.workEntries[0]).toMatchObject({
345 expect(harness.registry.get(otherThreadDetailAtom)).toBe(otherValue);110 itemType: "dynamic_tool",
346 111 label: "custom_tool",
347 harness.registry.set(112 structuredPayload: item,
348 harness.threadStateAtom(THREAD_ID),113 });
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);
369 });114 });
370});115});
packages/client-runtime/src/state/models.ts+705−15
@@ -1,25 +1,459 @@
1import type {1import type {
2 ChatAttachment,
3 CheckpointId,
4 CheckpointRef,
5 CheckpointScopeId,
2 EnvironmentId,6 EnvironmentId,
3 OrchestrationMessage,7 MessageId,
4 OrchestrationProjectShell,8 OrchestrationProjectShell,
5 OrchestrationShellSnapshot,9 OrchestrationV2PlanArtifact,
6 OrchestrationThread,10 OrchestrationV2ProviderCapabilities,
7 OrchestrationThreadShell,11 OrchestrationV2RunStatus,
12 OrchestrationV2ShellSnapshot,
13 OrchestrationV2ThreadProjection,
14 OrchestrationV2ThreadShell,
15 OrchestrationV2TurnItem,
16 PlanId,
17 ProjectId,
18 ProviderApprovalDecision,
19 ProviderInstanceId,
20 ProviderRequestKind,
21 RunId,
22 RuntimeRequestId,
8 ThreadId,23 ThreadId,
9} from "@t3tools/contracts";24} from "@t3tools/contracts";
25import * as DateTime from "effect/DateTime";
10 26
11export interface EnvironmentProject extends OrchestrationProjectShell {27export interface EnvironmentProject extends OrchestrationProjectShell {
12 readonly environmentId: EnvironmentId;28 readonly environmentId: EnvironmentId;
13}29}
14 30
15export 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 */
38export interface ScopedThreadProjection {
16 readonly environmentId: EnvironmentId;39 readonly environmentId: EnvironmentId;
40 readonly projection: OrchestrationV2ThreadProjection;
17}41}
18 42
19export type EnvironmentMessage = OrchestrationMessage;43export 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
56export 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
65export function threadRuntimeIsActive(runtime: ThreadRuntimeSummary | null | undefined): boolean {
66 return runtime !== null && runtime !== undefined && threadRunStatusIsActive(runtime.status);
67}
68
69export function threadRunStatusIsActive(status: ThreadRuntimeSummary["status"]): boolean {
70 return (
71 status === "queued" || status === "starting" || status === "running" || status === "waiting"
72 );
73}
74
75export 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
86export 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
97export 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
110export 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}
20 126
21export interface EnvironmentThread extends OrchestrationThread {127export 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
135export 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
146export interface ThreadPendingUserInput {
147 readonly requestId: RuntimeRequestId;
148 readonly createdAt: string;
149 readonly questions: ReadonlyArray<ThreadUserInputQuestion>;
150 readonly responseCapability: "live" | "not_resumable";
151}
152
153export type ThreadWorkEntryTone = "thinking" | "tool" | "info" | "error";
154export 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 */
161export 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
179export interface EnvironmentThreadShell {
22 readonly environmentId: EnvironmentId;180 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
208export 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
232function iso(value: DateTime.Utc): string {
233 return DateTime.formatIso(value);
234}
235
236function nullableIso(value: DateTime.Utc | null): string | null {
237 return value === null ? null : iso(value);
238}
239
240function 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
250function 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
266function 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
282function 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
392function 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
418function 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
435function presentLatestRun(projection: OrchestrationV2ThreadProjection): ThreadRunSummary | null {
436 return presentRuns(projection).at(-1) ?? null;
437}
438
439function 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 };
23}457}
24 458
25export function scopeProject(459export function scopeProject(
@@ -29,25 +463,281 @@ export function scopeProject( · 3 unmodified lines
29 return { ...project, environmentId };463 return { ...project, environmentId };
30}464}
31 465
32export function scopeThreadShell(466export function presentThreadShell(
33 environmentId: EnvironmentId,467 environmentId: EnvironmentId,
34 thread: OrchestrationThreadShell,468 thread: OrchestrationV2ThreadShell,
35): EnvironmentThreadShell {469): 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 };
37}514}
38 515
39export function scopeThread(516export const scopeThreadShell = presentThreadShell;
517
518function 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
533function 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
542function 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
563function 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
585function 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
623function 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
648export function presentThread(
40 environmentId: EnvironmentId,649 environmentId: EnvironmentId,
41 thread: OrchestrationThread,650 projection: OrchestrationV2ThreadProjection,
42): EnvironmentThread {651): 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 };
44}730}
45 731
46export function selectEnvironmentThreadShell(732export function selectEnvironmentThreadShell(
47 snapshot: OrchestrationShellSnapshot | null,733 snapshot: OrchestrationV2ShellSnapshot | null,
48 environmentId: EnvironmentId,734 environmentId: EnvironmentId,
49 threadId: ThreadId,735 threadId: ThreadId,
50): EnvironmentThreadShell | null {736): EnvironmentThreadShell | null {
51 const thread = snapshot?.threads.find((candidate) => candidate.id === threadId) ?? null;737 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
741export function providerApprovalDecisionIsFinal(_decision: ProviderApprovalDecision): boolean {
742 return true;
53}743}
packages/client-runtime/src/state/orchestration.ts+4−4
@@ -1,4 +1,4 @@
1import { ORCHESTRATION_WS_METHODS } from "@t3tools/contracts";1import { ORCHESTRATION_V2_WS_METHODS } from "@t3tools/contracts";
2import { Atom } from "effect/unstable/reactivity";2import { Atom } from "effect/unstable/reactivity";
3 3
4import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";4import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";
@@ -10,11 +10,11 @@ export function createOrchestrationEnvironmentAtoms<R, E>( · 5 unmodified lines
10 return {10 return {
11 turnDiff: createEnvironmentRpcQueryAtomFamily(runtime, {11 turnDiff: createEnvironmentRpcQueryAtomFamily(runtime, {
12 label: "environment-data:orchestration:turn-diff",12 label: "environment-data:orchestration:turn-diff",
13 tag: ORCHESTRATION_WS_METHODS.getTurnDiff,13 tag: ORCHESTRATION_V2_WS_METHODS.getTurnDiff,
14 }),14 }),
15 fullThreadDiff: createEnvironmentRpcQueryAtomFamily(runtime, {15 fullThreadDiff: createEnvironmentRpcQueryAtomFamily(runtime, {
16 label: "environment-data:orchestration:full-thread-diff",16 label: "environment-data:orchestration:full-thread-diff",
17 tag: ORCHESTRATION_WS_METHODS.getFullThreadDiff,17 tag: ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff,
18 }),18 }),
19 threadSearch: createEnvironmentRpcQueryAtomFamily(runtime, {19 threadSearch: createEnvironmentRpcQueryAtomFamily(runtime, {
20 label: "environment-data:orchestration:thread-search",20 label: "environment-data:orchestration:thread-search",
@@ -24,7 +24,7 @@ export function createOrchestrationEnvironmentAtoms<R, E>( · 3 unmodified lines
24 }),24 }),
25 archivedShellSnapshot: createEnvironmentRpcQueryAtomFamily(runtime, {25 archivedShellSnapshot: createEnvironmentRpcQueryAtomFamily(runtime, {
26 label: "environment-data:orchestration:archived-shell-snapshot",26 label: "environment-data:orchestration:archived-shell-snapshot",
27 tag: ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot,27 tag: ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot,
28 }),28 }),
29 };29 };
30}30}
packages/client-runtime/src/state/orchestrationV2Projection.test.ts+154−0
@@ -1,10 +1,15 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import {2import {
3 type OrchestrationV2DomainEvent,3 type OrchestrationV2DomainEvent,
4 type OrchestrationV2Run,
4 type OrchestrationV2ThreadProjection,5 type OrchestrationV2ThreadProjection,
6 type OrchestrationV2TurnItem,
7 MessageId,
5 ProjectId,8 ProjectId,
6 ProviderInstanceId,9 ProviderInstanceId,
10 RunId,
7 ThreadId,11 ThreadId,
12 TurnItemId,
8} from "@t3tools/contracts";13} from "@t3tools/contracts";
9import * as DateTime from "effect/DateTime";14import * as DateTime from "effect/DateTime";
10 15
@@ -12,6 +17,47 @@ import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection · 1 unmodified line
12 17
13const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");18const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
14const threadId = ThreadId.make("thread-reducer");19const threadId = ThreadId.make("thread-reducer");
20const runId = RunId.make("run-reducer");
21const 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
39function 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}
15const emptyProjection = {61const emptyProjection = {
16 thread: {62 thread: {
17 id: threadId,63 id: threadId,
@@ -79,4 +125,112 @@ describe("applyOrchestrationV2ProjectionEvent", () => { · 61 unmodified lines
79 125
80 expect(applyOrchestrationV2ProjectionEvent(emptyProjection, event)).toBe(emptyProjection);126 expect(applyOrchestrationV2ProjectionEvent(emptyProjection, event)).toBe(emptyProjection);
81 });127 });
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 });
82});236});
packages/client-runtime/src/state/orchestrationV2Projection.ts+34−9
@@ -20,9 +20,15 @@ function removeVisibleItem( · 19 unmodified lines
20 rows: OrchestrationV2ThreadProjection["visibleTurnItems"],20 rows: OrchestrationV2ThreadProjection["visibleTurnItems"],
21 sourceItemId: OrchestrationV2TurnItem["id"],21 sourceItemId: OrchestrationV2TurnItem["id"],
22): OrchestrationV2ThreadProjection["visibleTurnItems"] {22): OrchestrationV2ThreadProjection["visibleTurnItems"] {
23 return rows23 const index = rows.findIndex((row) => row.sourceItemId === sourceItemId);
24 .filter((row) => row.sourceItemId !== sourceItemId)24 if (index === -1) return rows;
25 .map((row, position) => ({ ...row, position }));25 return renumberVisibleItems([...rows.slice(0, index), ...rows.slice(index + 1)]);
26}
27
28function renumberVisibleItems(
29 rows: OrchestrationV2ThreadProjection["visibleTurnItems"],
30): OrchestrationV2ThreadProjection["visibleTurnItems"] {
31 return rows.map((row, position) => (row.position === position ? row : { ...row, position }));
26}32}
27 33
28function shouldShowLocalTurnItem(34function shouldShowLocalTurnItem(
@@ -39,9 +45,18 @@ function shouldShowLocalTurnItem( · 10 unmodified lines
39function activeVisibleTurnItems(45function activeVisibleTurnItems(
40 projection: OrchestrationV2ThreadProjection,46 projection: OrchestrationV2ThreadProjection,
41): OrchestrationV2ThreadProjection["visibleTurnItems"] {47): OrchestrationV2ThreadProjection["visibleTurnItems"] {
42 return projection.visibleTurnItems48 const rows = projection.visibleTurnItems;
43 .filter((row) => row.visibility !== "local" || shouldShowLocalTurnItem(projection, row.item))49 let next: Array<(typeof rows)[number]> | null = null;
44 .map((row, position) => ({ ...row, position }));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);
45}60}
46 61
47function upsertVisibleTurnItem(62function upsertVisibleTurnItem(
@@ -57,9 +72,19 @@ function upsertVisibleTurnItem( · 9 unmodified lines
57 sourceItemId: item.id,72 sourceItemId: item.id,
58 item,73 item,
59 };74 };
60 return index === -175 if (index === -1) return [...rows, next];
61 ? [...rows, next]76 const previous = rows[index]!;
62 : rows.map((row, rowIndex) => (rowIndex === index ? next : row));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;
63}88}
64 89
65/** Applies one committed event to a matching thread projection. */90/** Applies one committed event to a matching thread projection. */
packages/client-runtime/src/state/orchestrationV2TestFixtures.ts+104−0
@@ -0,0 +1,104 @@
1import {
2 ProjectId,
3 ProviderInstanceId,
4 ThreadId,
5 type OrchestrationProjectShell,
6 type OrchestrationV2ShellSnapshot,
7 type OrchestrationV2ThreadProjection,
8 type OrchestrationV2ThreadShell,
9} from "@t3tools/contracts";
10import * as DateTime from "effect/DateTime";
11
12export const v2Now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
13export const v2ProjectId = ProjectId.make("project-v2");
14export const v2ThreadId = ThreadId.make("thread-v2");
15export const v2ProviderInstanceId = ProviderInstanceId.make("codex");
16
17export 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
28export 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
58export 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
98export 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 @@
1import type {1import type {
2 EnvironmentId,2 EnvironmentId,
3 OrchestrationProjectShell,3 OrchestrationProjectShell,
4 OrchestrationShellSnapshot,4 OrchestrationV2ShellSnapshot,
5 ProjectId,5 ProjectId,
6 ScopedProjectRef,6 ScopedProjectRef,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
@@ -19,7 +19,7 @@ export function createEnvironmentProjectAtoms(input: { · 11 unmodified lines
19 readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>;19 readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>;
20 readonly snapshotAtom: (20 readonly snapshotAtom: (
21 environmentId: EnvironmentId,21 environmentId: EnvironmentId,
22 ) => Atom.Atom<OrchestrationShellSnapshot | null>;22 ) => Atom.Atom<OrchestrationV2ShellSnapshot | null>;
23}) {23}) {
24 const environmentProjectsAtom = Atom.family((environmentId: EnvironmentId) =>24 const environmentProjectsAtom = Atom.family((environmentId: EnvironmentId) =>
25 Atom.make(25 Atom.make(
packages/client-runtime/src/state/shell-sync.test.ts+9−19
@@ -1,8 +1,8 @@
1import {1import {
2 EnvironmentId,2 EnvironmentId,
3 ORCHESTRATION_WS_METHODS,3 ORCHESTRATION_V2_WS_METHODS,
4 type OrchestrationShellSnapshot,4 type OrchestrationV2ShellSnapshot,
5 type OrchestrationShellStreamItem,5 type OrchestrationV2ShellStreamItem,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import { describe, expect, it } from "@effect/vitest";7import { describe, expect, it } from "@effect/vitest";
8import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
@@ -22,7 +22,8 @@ import * as ConnectionWakeups from "../connection/wakeups.ts"; · 13 unmodified lines
22import * as Persistence from "../platform/persistence.ts";22import * as Persistence from "../platform/persistence.ts";
23import * as RpcSession from "../rpc/session.ts";23import * as RpcSession from "../rpc/session.ts";
24import type { WsRpcProtocolClient } from "../rpc/protocol.ts";24import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
25import { makeEnvironmentShellState, ShellSnapshotLoader } from "./shell.ts";25import { makeEnvironmentShellState } from "./shell.ts";
26import { v2ShellSnapshot } from "./orchestrationV2TestFixtures.ts";
26 27
27const TARGET = new PrimaryConnectionTarget({28const TARGET = new PrimaryConnectionTarget({
28 environmentId: EnvironmentId.make("environment-1"),29 environmentId: EnvironmentId.make("environment-1"),
@@ -31,20 +32,9 @@ const TARGET = new PrimaryConnectionTarget({ · 2 unmodified lines
31 wsBaseUrl: "wss://environment.example.test",32 wsBaseUrl: "wss://environment.example.test",
32});33});
33 34
34const PREPARED: PreparedConnection = {35const LIVE_SHELL_SNAPSHOT: OrchestrationV2ShellSnapshot = {
35 environmentId: TARGET.environmentId,36 ...v2ShellSnapshot,
36 label: TARGET.label,
37 httpBaseUrl: TARGET.httpBaseUrl,
38 socketUrl: TARGET.wsBaseUrl,
39 httpAuthorization: null,
40 target: TARGET,
41};
42
43const LIVE_SHELL_SNAPSHOT: OrchestrationShellSnapshot = {
44 snapshotSequence: 1,37 snapshotSequence: 1,
45 projects: [],
46 threads: [],
47 updatedAt: "2026-06-06T00:00:00.000Z",
48};38};
49 39
50function session(client: WsRpcProtocolClient): RpcSession.RpcSession {40function session(client: WsRpcProtocolClient): RpcSession.RpcSession {
@@ -60,9 +50,9 @@ function session(client: WsRpcProtocolClient): RpcSession.RpcSession { · 9 unmodified lines
60describe("environment shell synchronization", () => {50describe("environment shell synchronization", () => {
61 it.effect("publishes live state before persistence and preserves it when ready", () =>51 it.effect("publishes live state before persistence and preserves it when ready", () =>
62 Effect.gen(function* () {52 Effect.gen(function* () {
63 const events = yield* Queue.unbounded<OrchestrationShellStreamItem>();53 const events = yield* Queue.unbounded<OrchestrationV2ShellStreamItem>();
64 const client = {54 const client = {
65 [ORCHESTRATION_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events),55 [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events),
66 } as unknown as WsRpcProtocolClient;56 } as unknown as WsRpcProtocolClient;
67 const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE);57 const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE);
68 const activeSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>(58 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
2import { EnvironmentId } from "@t3tools/contracts";2import { EnvironmentId } from "@t3tools/contracts";
3import { describe, expect, it } from "@effect/vitest";3import { describe, expect, it } from "@effect/vitest";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
5import * as DateTime from "effect/DateTime";
5import { Atom, AtomRegistry } from "effect/unstable/reactivity";6import { Atom, AtomRegistry } from "effect/unstable/reactivity";
6 7
7import { PrimaryConnectionTarget } from "../connection/model.ts";8import { PrimaryConnectionTarget } from "../connection/model.ts";
8import type { EnvironmentShellState } from "./shell.ts";9import type { EnvironmentShellState } from "./shell.ts";
9import { createEnvironmentServerConfigsAtom, createEnvironmentShellSummaryAtom } from "./shell.ts";10import { createEnvironmentServerConfigsAtom, createEnvironmentShellSummaryAtom } from "./shell.ts";
11import { v2ThreadShell } from "./orchestrationV2TestFixtures.ts";
10 12
11const ENVIRONMENT_ID = EnvironmentId.make("environment-1");13const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
12const OTHER_ENVIRONMENT_ID = EnvironmentId.make("environment-2");14const OTHER_ENVIRONMENT_ID = EnvironmentId.make("environment-2");
@@ -34,10 +36,16 @@ function shellState(input: { · 21 unmodified lines
34 input.updatedAt === undefined36 input.updatedAt === undefined
35 ? Option.none()37 ? Option.none()
36 : Option.some({38 : Option.some({
39 schemaVersion: 1,
37 snapshotSequence: input.snapshotSequence ?? 1,40 snapshotSequence: input.snapshotSequence ?? 1,
38 updatedAt: input.updatedAt,
39 projects: [],41 projects: [],
40 threads: [],42 threads: [
43 {
44 ...v2ThreadShell,
45 updatedAt: DateTime.makeUnsafe(input.updatedAt),
46 },
47 ],
48 archivedThreads: [],
41 }),49 }),
42 status: input.status,50 status: input.status,
43 error: input.error === undefined ? Option.none() : Option.some(input.error),51 error: input.error === undefined ? Option.none() : Option.some(input.error),
packages/client-runtime/src/state/shell.ts+31−62
@@ -1,11 +1,12 @@
1import {1import {
2 ORCHESTRATION_WS_METHODS,2 ORCHESTRATION_V2_WS_METHODS,
3 type EnvironmentId,3 type EnvironmentId,
4 type OrchestrationShellSnapshot,4 type OrchestrationV2ShellSnapshot,
5 type OrchestrationShellStreamItem,5 type OrchestrationV2ShellStreamItem,
6 type ServerConfig,6 type ServerConfig,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import * as Cause from "effect/Cause";8import * as Cause from "effect/Cause";
9import * as DateTime from "effect/DateTime";
9import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
10import * as Option from "effect/Option";11import * as Option from "effect/Option";
11import * as Queue from "effect/Queue";12import * as Queue from "effect/Queue";
@@ -29,7 +30,7 @@ import { followStreamInEnvironment } from "./runtime.ts"; · 17 unmodified lines
29export type EnvironmentShellStatus = "empty" | "cached" | "synchronizing" | "live";30export type EnvironmentShellStatus = "empty" | "cached" | "synchronizing" | "live";
30 31
31export interface EnvironmentShellState {32export interface EnvironmentShellState {
32 readonly snapshot: Option.Option<OrchestrationShellSnapshot>;33 readonly snapshot: Option.Option<OrchestrationV2ShellSnapshot>;
33 readonly status: EnvironmentShellStatus;34 readonly status: EnvironmentShellStatus;
34 readonly error: Option.Option<string>;35 readonly error: Option.Option<string>;
35}36}
@@ -41,7 +42,7 @@ const EMPTY_SHELL_STATE: EnvironmentShellState = { · 5 unmodified lines
41};42};
42 43
43function shellStatusForSnapshot(44function shellStatusForSnapshot(
44 snapshot: Option.Option<OrchestrationShellSnapshot>,45 snapshot: Option.Option<OrchestrationV2ShellSnapshot>,
45): EnvironmentShellStatus {46): EnvironmentShellStatus {
46 return Option.isSome(snapshot) ? "cached" : "empty";47 return Option.isSome(snapshot) ? "cached" : "empty";
47}48}
@@ -61,7 +62,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 13 unmodified lines
61 environmentId,62 environmentId,
62 ...safeErrorLogAttributes(error),63 ...safeErrorLogAttributes(error),
63 }),64 }),
64 Effect.as(Option.none<OrchestrationShellSnapshot>()),65 Effect.as(Option.none<OrchestrationV2ShellSnapshot>()),
65 ),66 ),
66 ),67 ),
67 );68 );
@@ -70,11 +71,10 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 2 unmodified lines
70 status: shellStatusForSnapshot(cachedSnapshot),71 status: shellStatusForSnapshot(cachedSnapshot),
71 error: Option.none(),72 error: Option.none(),
72 });73 });
73 const awaitingCompletion = yield* Ref.make(false);74 const persistence = yield* Queue.sliding<OrchestrationV2ShellSnapshot>(1);
74 const persistence = yield* Queue.sliding<OrchestrationShellSnapshot>(1);
75 75
76 const persist = Effect.fn("EnvironmentShellState.persist")(function* (76 const persist = Effect.fn("EnvironmentShellState.persist")(function* (
77 snapshot: OrchestrationShellSnapshot,77 snapshot: OrchestrationV2ShellSnapshot,
78 ) {78 ) {
79 yield* cache.saveShell(environmentId, snapshot).pipe(79 yield* cache.saveShell(environmentId, snapshot).pipe(
80 Effect.catch((error) =>80 Effect.catch((error) =>
@@ -133,7 +133,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 52 unmodified lines
133 );133 );
134 134
135 const applyItem = Effect.fn("EnvironmentShellState.applyItem")(function* (135 const applyItem = Effect.fn("EnvironmentShellState.applyItem")(function* (
136 item: OrchestrationShellStreamItem,136 item: OrchestrationV2ShellStreamItem,
137 ) {137 ) {
138 if (item.kind === "synchronized") {138 if (item.kind === "synchronized") {
139 yield* Ref.set(awaitingCompletion, false);139 yield* Ref.set(awaitingCompletion, false);
@@ -169,56 +169,13 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 29 unmodified lines
169 yield* Queue.offer(persistence, nextSnapshot);169 yield* Queue.offer(persistence, nextSnapshot);
170 });170 });
171 171
172 const foregroundResubscriptions = Option.match(wakeups, {172 yield* subscribe(
173 onNone: () => Stream.never,173 ORCHESTRATION_V2_WS_METHODS.subscribeShell,
174 onSome: (service) =>174 {},
175 service.changes.pipe(Stream.filter(ConnectionWakeups.shouldResubscribeAfterWakeup)),175 {
176 });176 onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)),
177 177 },
178 yield* setSynchronizing;178 ).pipe(Stream.runForEach(applyItem), Effect.forkScoped);
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 );
222 yield* SubscriptionRef.changes(supervisor.state).pipe(179 yield* SubscriptionRef.changes(supervisor.state).pipe(
223 Stream.runForEach((connectionState) => {180 Stream.runForEach((connectionState) => {
224 switch (connectionProjectionPhase(connectionState)) {181 switch (connectionProjectionPhase(connectionState)) {
@@ -314,8 +271,20 @@ export function createEnvironmentShellSummaryAtom(input: { · 89 unmodified lines
314 continue;271 continue;
315 }272 }
316 hasSnapshot = true;273 hasSnapshot = true;
317 const updatedAt = state.snapshot.value.updatedAt;274 const snapshot = state.snapshot.value;
318 if (latestSnapshotUpdatedAt === null || updatedAt > latestSnapshotUpdatedAt) {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 ) {
319 latestSnapshotUpdatedAt = updatedAt;288 latestSnapshotUpdatedAt = updatedAt;
320 }289 }
321 }290 }
packages/client-runtime/src/state/shellReducer.test.ts+52−153
@@ -1,185 +1,84 @@
1import { ProjectId, ThreadId } from "@t3tools/contracts";
1import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
2 3
3import { ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";4import { v2Project, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts";
4import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts";
5
6import { applyShellStreamEvent } from "./shellReducer.ts";5import { applyShellStreamEvent } from "./shellReducer.ts";
7 6
8const baseSnapshot: OrchestrationShellSnapshot = {
9 snapshotSequence: 0,
10 projects: [],
11 threads: [],
12 updatedAt: "2026-04-01T00:00:00.000Z",
13};
14
15const 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
26const 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
48describe("applyShellStreamEvent", () => {7describe("applyShellStreamEvent", () => {
49 it("ignores stale project upserts without mutating the snapshot", () => {8 it("ignores stale project updates without mutating the snapshot", () => {
50 const snapshotWithProject: OrchestrationShellSnapshot = {9 const snapshotWithProject = {
51 ...baseSnapshot,10 ...v2ShellSnapshot,
52 snapshotSequence: 4,11 snapshotSequence: 4,
53 projects: [stubProject],12 projects: [v2Project],
54 };13 };
55 14
56 for (const sequence of [3, 4]) {15 for (const sequence of [3, 4]) {
57 const next = applyShellStreamEvent(snapshotWithProject, {16 const next = applyShellStreamEvent(snapshotWithProject, {
58 kind: "project-upserted",17 kind: "project.updated",
59 sequence,18 sequence,
60 project: { ...stubProject, title: "Stale Title" },19 project: { ...v2Project, title: "Stale Title" },
61 });20 });
62 21
63 expect(next).toBe(snapshotWithProject);22 expect(next).toBe(snapshotWithProject);
64 expect(next.snapshotSequence).toBe(4);23 expect(next.snapshotSequence).toBe(4);
65 expect(next.projects[0]?.title).toBe("Test Project");24 expect(next.projects[0]?.title).toBe(v2Project.title);
66 }25 }
67 });26 });
68 27
69 describe("project-upserted", () => {28 it("applies project updates and removals", () => {
70 it("adds a new project", () => {29 const updated = applyShellStreamEvent(v2ShellSnapshot, {
71 const event: OrchestrationShellStreamEvent = {30 kind: "project.updated",
72 kind: "project-upserted",31 sequence: 1,
73 sequence: 1,32 project: { ...v2Project, title: "Updated" },
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);
82 });33 });
34 expect(updated.projects[0]?.title).toBe("Updated");
35 expect(updated.snapshotSequence).toBe(1);
83 36
84 it("updates an existing project", () => {37 const removed = applyShellStreamEvent(updated, {
85 const snapshotWithProject: OrchestrationShellSnapshot = {38 kind: "project.removed",
86 ...baseSnapshot,39 sequence: 2,
87 projects: [stubProject],40 projectId: ProjectId.make(v2Project.id),
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);
122 });41 });
42 expect(removed.projects).toEqual([]);
123 });43 });
124 44
125 describe("thread-upserted", () => {45 it("moves a thread between active and archive without duplicating it", () => {
126 it("adds a new thread", () => {46 const archived = applyShellStreamEvent(v2ShellSnapshot, {
127 const event: OrchestrationShellStreamEvent = {47 kind: "thread.updated",
128 kind: "thread-upserted",48 sequence: 3,
129 sequence: 4,49 location: "archive",
130 thread: stubThread,50 thread: { ...v2ThreadShell, archivedAt: v2ThreadShell.updatedAt },
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);
138 });51 });
139 52 expect(archived.threads).toEqual([]);
140 it("updates an existing thread", () => {53 expect(archived.archivedThreads).toHaveLength(1);
141 const snapshotWithThread: OrchestrationShellSnapshot = {54
142 ...baseSnapshot,55 const active = applyShellStreamEvent(archived, {
143 threads: [stubThread],56 kind: "thread.updated",
144 };57 sequence: 4,
145 58 location: "active",
146 const updatedThread = { ...stubThread, title: "Updated Thread" };59 thread: v2ThreadShell,
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");
157 });60 });
61 expect(active.threads).toHaveLength(1);
62 expect(active.archivedThreads).toEqual([]);
158 });63 });
159 64
160 describe("thread-removed", () => {65 it("removes a thread from either collection", () => {
161 it("removes a thread by id", () => {66 const next = applyShellStreamEvent(v2ShellSnapshot, {
162 const snapshotWithThread: OrchestrationShellSnapshot = {67 kind: "thread.removed",
163 ...baseSnapshot,68 sequence: 5,
164 threads: [stubThread],69 location: "active",
165 };70 threadId: ThreadId.make(v2ThreadShell.id),
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);
177 });71 });
72 expect(next.threads).toEqual([]);
73 expect(next.snapshotSequence).toBe(5);
178 });74 });
179 75
180 it("returns original snapshot for unrecognized event kinds", () => {76 it("leaves unknown future events unchanged", () => {
181 const unknownEvent = { kind: "unknown-future-event", sequence: 99 } as any;77 const next = applyShellStreamEvent(
182 const next = applyShellStreamEvent(baseSnapshot, unknownEvent);78 v2ShellSnapshot,
183 expect(next).toBe(baseSnapshot);79 { kind: "future.event", sequence: 99 } as never,
80 );
81
82 expect(next).toBe(v2ShellSnapshot);
184 });83 });
185});84});
packages/client-runtime/src/state/shellReducer.ts+43−28
@@ -1,43 +1,58 @@
1import * as Arr from "effect/Array";1import type {
2import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts";2 OrchestrationV2ShellSnapshot,
3 OrchestrationV2ShellStreamItem,
4} from "@t3tools/contracts";
3 5
4/**6function upsertById<T extends { readonly id: unknown }>(
5 * Reduce a single shell stream event into an existing snapshot, returning a new7 items: ReadonlyArray<T>,
6 * snapshot with the event's changes applied. This is a pure reducer that both8 item: T,
7 * web and mobile can use to keep their local shell snapshot in sync.9): ReadonlyArray<T> {
8 *10 const index = items.findIndex((candidate) => candidate.id === item.id);
9 * Returns the original snapshot reference unchanged if the event is not11 if (index === -1) return [...items, item];
10 * recognized (forward-compatible).12 return items.map((candidate, candidateIndex) => (candidateIndex === index ? item : candidate));
11 */13}
14
15/** Applies one committed V2 shell delta while preserving active/archive exclusivity. */
12export function applyShellStreamEvent(16export function applyShellStreamEvent(
13 snapshot: OrchestrationShellSnapshot,17 snapshot: OrchestrationV2ShellSnapshot,
14 event: OrchestrationShellStreamEvent,18 event: Exclude<OrchestrationV2ShellStreamItem, { readonly kind: "snapshot" }>,
15): OrchestrationShellSnapshot {19): OrchestrationV2ShellSnapshot {
16 if (event.sequence <= snapshot.snapshotSequence) return snapshot;20 if (event.sequence <= snapshot.snapshotSequence) return snapshot;
17 21
18 switch (event.kind) {22 switch (event.kind) {
19 case "project-upserted": {23 case "project.updated":
20 const projects = snapshot.projects.some((p) => p.id === event.project.id)24 return {
21 ? Arr.map(snapshot.projects, (p) => (p.id === event.project.id ? event.project : p))25 ...snapshot,
22 : Arr.append(snapshot.projects, event.project);26 projects: upsertById(snapshot.projects, event.project),
23 return { ...snapshot, projects, snapshotSequence: event.sequence };27 snapshotSequence: event.sequence,
24 }28 };
25 case "project-removed":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);
26 return {38 return {
27 ...snapshot,39 ...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),
29 snapshotSequence: event.sequence,48 snapshotSequence: event.sequence,
30 };49 };
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 };
36 }50 }
37 case "thread-removed":51 case "thread.removed":
38 return {52 return {
39 ...snapshot,53 ...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),
41 snapshotSequence: event.sequence,56 snapshotSequence: event.sequence,
42 };57 };
43 default:58 default:
packages/client-runtime/src/state/snapshots.ts+2−2
@@ -1,4 +1,4 @@
1import type { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts";1import type { EnvironmentId, OrchestrationV2ShellSnapshot } from "@t3tools/contracts";
2import * as Option from "effect/Option";2import * as Option from "effect/Option";
3import { AsyncResult, Atom } from "effect/unstable/reactivity";3import { AsyncResult, Atom } from "effect/unstable/reactivity";
4 4
@@ -10,7 +10,7 @@ export function createEnvironmentSnapshotAtom<E>( · 5 unmodified lines
10 ) => Atom.Atom<AsyncResult.AsyncResult<EnvironmentShellState, E>>,10 ) => Atom.Atom<AsyncResult.AsyncResult<EnvironmentShellState, E>>,
11) {11) {
12 return Atom.family((environmentId: EnvironmentId) =>12 return Atom.family((environmentId: EnvironmentId) =>
13 Atom.make((get): OrchestrationShellSnapshot | null =>13 Atom.make((get): OrchestrationV2ShellSnapshot | null =>
14 Option.match(AsyncResult.value(get(shellStateAtom(environmentId))), {14 Option.match(AsyncResult.value(get(shellStateAtom(environmentId))), {
15 onNone: () => null,15 onNone: () => null,
16 onSome: (state) => Option.getOrNull(state.snapshot),16 onSome: (state) => Option.getOrNull(state.snapshot),
packages/client-runtime/src/state/threadCommands.ts+1−0
@@ -54,6 +54,7 @@ export type { · 53 unmodified lines
54 SnoozeThreadInput,54 SnoozeThreadInput,
55 StartThreadTurnInput,55 StartThreadTurnInput,
56 StopThreadSessionInput,56 StopThreadSessionInput,
57 ThreadCommandInput,
57 UnarchiveThreadInput,58 UnarchiveThreadInput,
58 UnsettleThreadInput,59 UnsettleThreadInput,
59 UnsnoozeThreadInput,60 UnsnoozeThreadInput,
packages/client-runtime/src/state/threadDetail.test.ts+45−0
@@ -0,0 +1,45 @@
1import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts";
2import * as Option from "effect/Option";
3import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
4import { describe, expect, it } from "vite-plus/test";
5
6import { v2Projection } from "./orchestrationV2TestFixtures.ts";
7import { createEnvironmentThreadDetailAtoms } from "./threadDetail.ts";
8import type { EnvironmentThreadState } from "./threads.ts";
9
10const ref: ScopedThreadRef = {
11 environmentId: EnvironmentId.make("environment-detail"),
12 threadId: ThreadId.make(v2Projection.thread.id),
13};
14
15describe("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 @@
1import type {1import type { OrchestrationV2ThreadProjection, ScopedThreadRef } from "@t3tools/contracts";
2 OrchestrationCheckpointSummary,
3 OrchestrationLatestTurn,
4 OrchestrationMessage,
5 OrchestrationProposedPlan,
6 OrchestrationSession,
7 OrchestrationThread,
8 OrchestrationThreadActivity,
9 ScopedThreadRef,
10} from "@t3tools/contracts";
11import * as Option from "effect/Option";2import * as Option from "effect/Option";
12import { AsyncResult, Atom } from "effect/unstable/reactivity";3import { AsyncResult, Atom } from "effect/unstable/reactivity";
13 4
14import type { EnvironmentThread, EnvironmentThreadShell } from "./models.ts";5import type {
15import { scopeThread } from "./models.ts";6 EnvironmentThread,
16import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threadState.ts";7 EnvironmentThreadShell,
8 ThreadCheckpointSummary,
9 ThreadConversationMessage,
10 ThreadPendingApproval,
11 ThreadPendingUserInput,
12 ThreadProposedPlan,
13 ThreadRunSummary,
14 ThreadRuntimeSummary,
15 ScopedThreadProjection,
16 ThreadWorkEntry,
17} from "./models.ts";
18import { presentThread } from "./models.ts";
19import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts";
17import { parseThreadKey, threadKey } from "./entities.ts";20import { parseThreadKey, threadKey } from "./entities.ts";
18import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";21import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
19 22
20const EMPTY_MESSAGES: ReadonlyArray<OrchestrationMessage> = Object.freeze([]);23const EMPTY_MESSAGES: ReadonlyArray<ThreadConversationMessage> = Object.freeze([]);
21const EMPTY_ACTIVITIES: ReadonlyArray<OrchestrationThreadActivity> = Object.freeze([]);24const EMPTY_WORK_ENTRIES: ReadonlyArray<ThreadWorkEntry> = Object.freeze([]);
22const EMPTY_PROPOSED_PLANS: ReadonlyArray<OrchestrationProposedPlan> = Object.freeze([]);25const EMPTY_PROPOSED_PLANS: ReadonlyArray<ThreadProposedPlan> = Object.freeze([]);
23const EMPTY_CHECKPOINTS: ReadonlyArray<OrchestrationCheckpointSummary> = Object.freeze([]);26const EMPTY_CHECKPOINTS: ReadonlyArray<ThreadCheckpointSummary> = Object.freeze([]);
27const EMPTY_APPROVALS: ReadonlyArray<ThreadPendingApproval> = Object.freeze([]);
28const EMPTY_USER_INPUTS: ReadonlyArray<ThreadPendingUserInput> = Object.freeze([]);
29const EMPTY_VISIBLE_TURN_ITEMS: OrchestrationV2ThreadProjection["visibleTurnItems"] = Object.freeze(
30 [],
31);
24 32
25/**33/** Shell metadata wins over an independently cached detail projection. */
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 */
34export function mergeEnvironmentThread(34export function mergeEnvironmentThread(
35 detail: EnvironmentThread | null,35 detail: EnvironmentThread | null,
36 shell: EnvironmentThreadShell | null,36 shell: EnvironmentThreadShell | null,
37): EnvironmentThread | null {37): EnvironmentThread | null {
38 if (detail === null || shell === null) {38 if (detail === null || shell === null) return detail;
39 return detail;39 if (detail.environmentId !== shell.environmentId || detail.id !== shell.id) return detail;
40 }
41 if (detail.environmentId !== shell.environmentId || detail.id !== shell.id) {
42 return detail;
43 }
44
45 return {40 return {
46 ...detail,41 ...detail,
47 environmentId: shell.environmentId,42 ...shell,
48 id: shell.id,43 latestRun: detail.latestRun,
49 projectId: shell.projectId,44 runtime: detail.runtime,
50 title: shell.title,45 source: shell.source,
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,
65 };46 };
66}47}
67 48
@@ -86,15 +67,13 @@ export function createEnvironmentThreadDetailAtoms<E>( · 18 unmodified lines
86 67
87 const threadDetailAtomFamily = Atom.family((key: string) => {68 const threadDetailAtomFamily = Atom.family((key: string) => {
88 const ref = parseThreadKey(key);69 const ref = parseThreadKey(key);
89 let previousSource: OrchestrationThread | null = null;70 let previousSource: OrchestrationV2ThreadProjection | null = null;
90 let previousValue: EnvironmentThread | null = null;71 let previousValue: EnvironmentThread | null = null;
91 return Atom.make((get) => {72 return Atom.make((get) => {
92 const source = Option.getOrNull(get(threadStateValueAtomFamily(key)).data);73 const source = Option.getOrNull(get(threadStateValueAtomFamily(key)).data);
93 if (source === previousSource) {74 if (source === previousSource) return previousValue;
94 return previousValue;
95 }
96 previousSource = source;75 previousSource = source;
97 previousValue = source === null ? null : scopeThread(ref.environmentId, source);76 previousValue = source === null ? null : presentThread(ref.environmentId, source);
98 return previousValue;77 return previousValue;
99 }).pipe(78 }).pipe(
100 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),79 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
@@ -102,88 +81,96 @@ export function createEnvironmentThreadDetailAtoms<E>( · 1 unmodified line
102 );81 );
103 });82 });
104 83
105 const threadStatusAtomFamily = Atom.family((key: string) =>84 const scopedThreadProjectionAtomFamily = Atom.family((key: string) => {
106 Atom.make((get) => get(threadStateValueAtomFamily(key)).status).pipe(85 const ref = parseThreadKey(key);
107 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),86 let previousProjection: OrchestrationV2ThreadProjection | null = null;
108 Atom.withLabel(`environment-thread-status:${key}`),87 let previousValue: ScopedThreadProjection | null = null;
109 ),88 return Atom.make((get) => {
110 );89 const projection = Option.getOrNull(get(threadStateValueAtomFamily(key)).data);
111 90 if (projection === previousProjection) return previousValue;
112 const threadErrorAtomFamily = Atom.family((key: string) =>91 previousProjection = projection;
113 Atom.make((get) => Option.getOrNull(get(threadStateValueAtomFamily(key)).error)).pipe(92 previousValue = projection === null ? null : { environmentId: ref.environmentId, projection };
93 return previousValue;
94 }).pipe(
114 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),95 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
115 Atom.withLabel(`environment-thread-error:${key}`),96 Atom.withLabel(`scoped-thread-projection:${key}`),
116 ),97 );
117 );98 });
118 99
119 const threadMessagesAtomFamily = Atom.family((key: string) =>100 const visibleTurnItemsAtomFamily = Atom.family((key: string) =>
120 Atom.make(101 Atom.make(
121 (get): ReadonlyArray<OrchestrationMessage> =>102 (get): OrchestrationV2ThreadProjection["visibleTurnItems"] =>
122 get(threadDetailAtomFamily(key))?.messages ?? EMPTY_MESSAGES,103 Option.getOrNull(get(threadStateValueAtomFamily(key)).data)?.visibleTurnItems ??
104 EMPTY_VISIBLE_TURN_ITEMS,
123 ).pipe(105 ).pipe(
124 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),106 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
125 Atom.withLabel(`environment-thread-messages:${key}`),107 Atom.withLabel(`environment-thread-visible-turn-items:${key}`),
126 ),108 ),
127 );109 );
128 110
129 const threadActivitiesAtomFamily = Atom.family((key: string) =>111 const family = <A>(label: string, select: (thread: EnvironmentThread) => A, empty: A) =>
130 Atom.make(112 Atom.family((key: string) =>
131 (get): ReadonlyArray<OrchestrationThreadActivity> =>113 Atom.make((get): A => {
132 get(threadDetailAtomFamily(key))?.activities ?? EMPTY_ACTIVITIES,114 const thread = get(threadDetailAtomFamily(key));
133 ).pipe(115 return thread === null ? empty : select(thread);
134 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),116 }).pipe(
135 Atom.withLabel(`environment-thread-activities:${key}`),117 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
136 ),118 Atom.withLabel(`environment-thread-${label}:${key}`),
137 );119 ),
120 );
138 121
139 const threadProposedPlansAtomFamily = Atom.family((key: string) =>122 const messages = family("messages", (thread) => thread.messages, EMPTY_MESSAGES);
140 Atom.make(123 const workEntries = family("work-entries", (thread) => thread.workEntries, EMPTY_WORK_ENTRIES);
141 (get): ReadonlyArray<OrchestrationProposedPlan> =>124 const proposedPlans = family(
142 get(threadDetailAtomFamily(key))?.proposedPlans ?? EMPTY_PROPOSED_PLANS,125 "proposed-plans",
143 ).pipe(126 (thread) => thread.proposedPlans,
144 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),127 EMPTY_PROPOSED_PLANS,
145 Atom.withLabel(`environment-thread-proposed-plans:${key}`),
146 ),
147 );128 );
148 129 const checkpoints = family("checkpoints", (thread) => thread.checkpoints, EMPTY_CHECKPOINTS);
149 const threadCheckpointsAtomFamily = Atom.family((key: string) =>130 const pendingApprovals = family(
150 Atom.make(131 "pending-approvals",
151 (get): ReadonlyArray<OrchestrationCheckpointSummary> =>132 (thread) => thread.pendingApprovals,
152 get(threadDetailAtomFamily(key))?.checkpoints ?? EMPTY_CHECKPOINTS,133 EMPTY_APPROVALS,
153 ).pipe(134 );
154 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),135 const pendingUserInputs = family(
155 Atom.withLabel(`environment-thread-checkpoints:${key}`),136 "pending-user-inputs",
156 ),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,
157 );145 );
158 146
159 const threadSessionAtomFamily = Atom.family((key: string) =>147 const status = Atom.family((key: string) =>
160 Atom.make(148 Atom.make((get) => get(threadStateValueAtomFamily(key)).status).pipe(
161 (get): OrchestrationSession | null => get(threadDetailAtomFamily(key))?.session ?? null,
162 ).pipe(
163 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),149 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
164 Atom.withLabel(`environment-thread-session:${key}`),150 Atom.withLabel(`environment-thread-status:${key}`),
165 ),151 ),
166 );152 );
167 153 const error = Atom.family((key: string) =>
168 const threadLatestTurnAtomFamily = Atom.family((key: string) =>154 Atom.make((get) => Option.getOrNull(get(threadStateValueAtomFamily(key)).error)).pipe(
169 Atom.make(
170 (get): OrchestrationLatestTurn | null => get(threadDetailAtomFamily(key))?.latestTurn ?? null,
171 ).pipe(
172 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),155 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
173 Atom.withLabel(`environment-thread-latest-turn:${key}`),156 Atom.withLabel(`environment-thread-error:${key}`),
174 ),157 ),
175 );158 );
176 159
177 return {160 return {
178 stateAtom: (ref: ScopedThreadRef) => threadStateValueAtomFamily(threadKey(ref)),161 stateAtom: (ref: ScopedThreadRef) => threadStateValueAtomFamily(threadKey(ref)),
162 threadAtom: (ref: ScopedThreadRef) => scopedThreadProjectionAtomFamily(threadKey(ref)),
163 visibleTurnItemsAtom: (ref: ScopedThreadRef) => visibleTurnItemsAtomFamily(threadKey(ref)),
179 detailAtom: (ref: ScopedThreadRef) => threadDetailAtomFamily(threadKey(ref)),164 detailAtom: (ref: ScopedThreadRef) => threadDetailAtomFamily(threadKey(ref)),
180 statusAtom: (ref: ScopedThreadRef) => threadStatusAtomFamily(threadKey(ref)),165 statusAtom: (ref: ScopedThreadRef) => status(threadKey(ref)),
181 errorAtom: (ref: ScopedThreadRef) => threadErrorAtomFamily(threadKey(ref)),166 errorAtom: (ref: ScopedThreadRef) => error(threadKey(ref)),
182 messagesAtom: (ref: ScopedThreadRef) => threadMessagesAtomFamily(threadKey(ref)),167 messagesAtom: (ref: ScopedThreadRef) => messages(threadKey(ref)),
183 activitiesAtom: (ref: ScopedThreadRef) => threadActivitiesAtomFamily(threadKey(ref)),168 workEntriesAtom: (ref: ScopedThreadRef) => workEntries(threadKey(ref)),
184 proposedPlansAtom: (ref: ScopedThreadRef) => threadProposedPlansAtomFamily(threadKey(ref)),169 proposedPlansAtom: (ref: ScopedThreadRef) => proposedPlans(threadKey(ref)),
185 checkpointsAtom: (ref: ScopedThreadRef) => threadCheckpointsAtomFamily(threadKey(ref)),170 checkpointsAtom: (ref: ScopedThreadRef) => checkpoints(threadKey(ref)),
186 sessionAtom: (ref: ScopedThreadRef) => threadSessionAtomFamily(threadKey(ref)),171 pendingApprovalsAtom: (ref: ScopedThreadRef) => pendingApprovals(threadKey(ref)),
187 latestTurnAtom: (ref: ScopedThreadRef) => threadLatestTurnAtomFamily(threadKey(ref)),172 pendingUserInputsAtom: (ref: ScopedThreadRef) => pendingUserInputs(threadKey(ref)),
173 runtimeAtom: (ref: ScopedThreadRef) => runtime(threadKey(ref)),
174 latestRunAtom: (ref: ScopedThreadRef) => latestRun(threadKey(ref)),
188 };175 };
189}176}
packages/client-runtime/src/state/threadReducer.test.ts+0−767
@@ -1,767 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 CheckpointRef,
5 CommandId,
6 EventId,
7 MessageId,
8 ProjectId,
9 ProviderInstanceId,
10 ThreadId,
11 TurnId,
12} from "@t3tools/contracts";
13import type { OrchestrationThread } from "@t3tools/contracts";
14
15import { applyThreadDetailEvent } from "./threadReducer.ts";
16
17const baseEventFields = {
18 eventId: EventId.make("event-1"),
19 commandId: null,
20 causationEventId: null,
21 correlationId: null,
22 metadata: {},
23} as const;
24
25const 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
48describe("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 @@
1import { pipe } from "effect/Function";
2import * as Arr from "effect/Array";
3import * as O from "effect/Order";
4import type {
5 MessageId,
6 OrchestrationCheckpointSummary,
7 OrchestrationEvent,
8 OrchestrationLatestTurn,
9 OrchestrationMessage,
10 OrchestrationSession,
11 OrchestrationThread,
12 OrchestrationThreadActivity,
13 TurnId,
14} from "@t3tools/contracts";
15
16export type ThreadDetailReducerResult =
17 | { readonly kind: "updated"; readonly thread: OrchestrationThread }
18 | { readonly kind: "deleted" }
19 | { readonly kind: "unchanged" };
20
21const proposedPlanOrder = O.combine<OrchestrationThread["proposedPlans"][number]>(
22 O.mapInput(O.String, (p) => p.createdAt),
23 O.mapInput(O.String, (p) => p.id),
24);
25
26const checkpointOrder = O.mapInput(
27 O.Number,
28 (cp: OrchestrationThread["checkpoints"][number]) =>
29 cp.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER,
30);
31
32const 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 */
47export 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 */
543function 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
561function 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
574function 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
584function 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 @@
1import type {1import type {
2 EnvironmentId,2 EnvironmentId,
3 OrchestrationShellSnapshot,3 OrchestrationV2ShellSnapshot,
4 OrchestrationThreadShell,4 OrchestrationV2ThreadShell,
5 ProjectId,5 ProjectId,
6 ScopedProjectRef,6 ScopedProjectRef,
7 ScopedThreadRef,7 ScopedThreadRef,
@@ -10,7 +10,7 @@ import type { · 2 unmodified lines
10import { Atom } from "effect/unstable/reactivity";10import { Atom } from "effect/unstable/reactivity";
11 11
12import type { EnvironmentThreadShell } from "./models.ts";12import type { EnvironmentThreadShell } from "./models.ts";
13import { scopeThreadShell } from "./models.ts";13import { presentThreadShell } from "./models.ts";
14import type { EnvironmentCatalogState } from "./connections.ts";14import type { EnvironmentCatalogState } from "./connections.ts";
15import {15import {
16 arrayElementsEqual,16 arrayElementsEqual,
@@ -21,9 +21,9 @@ import { · 4 unmodified lines
21 threadRefsEqual,21 threadRefsEqual,
22} from "./entities.ts";22} from "./entities.ts";
23 23
24const EMPTY_THREADS: ReadonlyArray<OrchestrationThreadShell> = Object.freeze([]);24const EMPTY_THREADS: ReadonlyArray<OrchestrationV2ThreadShell> = Object.freeze([]);
25const EMPTY_SCOPED_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]);25const EMPTY_SCOPED_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]);
26const EMPTY_THREAD_INDEX: ReadonlyMap<ThreadId, OrchestrationThreadShell> = new Map();26const EMPTY_THREAD_INDEX: ReadonlyMap<ThreadId, OrchestrationV2ThreadShell> = new Map();
27const EMPTY_THREAD_REFS_BY_PROJECT: ReadonlyMap<27const EMPTY_THREAD_REFS_BY_PROJECT: ReadonlyMap<
28 ProjectId,28 ProjectId,
29 ReadonlyArray<ScopedThreadRef>29 ReadonlyArray<ScopedThreadRef>
@@ -33,17 +33,17 @@ export function createEnvironmentThreadShellAtoms(input: { · 3 unmodified lines
33 readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>;33 readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>;
34 readonly snapshotAtom: (34 readonly snapshotAtom: (
35 environmentId: EnvironmentId,35 environmentId: EnvironmentId,
36 ) => Atom.Atom<OrchestrationShellSnapshot | null>;36 ) => Atom.Atom<OrchestrationV2ShellSnapshot | null>;
37}) {37}) {
38 const environmentThreadsAtom = Atom.family((environmentId: EnvironmentId) =>38 const environmentThreadsAtom = Atom.family((environmentId: EnvironmentId) =>
39 Atom.make(39 Atom.make(
40 (get): ReadonlyArray<OrchestrationThreadShell> =>40 (get): ReadonlyArray<OrchestrationV2ThreadShell> =>
41 get(input.snapshotAtom(environmentId))?.threads ?? EMPTY_THREADS,41 get(input.snapshotAtom(environmentId))?.threads ?? EMPTY_THREADS,
42 ).pipe(Atom.withLabel(`environment-threads:${environmentId}`)),42 ).pipe(Atom.withLabel(`environment-threads:${environmentId}`)),
43 );43 );
44 44
45 const environmentThreadIndexAtom = Atom.family((environmentId: EnvironmentId) =>45 const environmentThreadIndexAtom = Atom.family((environmentId: EnvironmentId) =>
46 Atom.make((get): ReadonlyMap<ThreadId, OrchestrationThreadShell> => {46 Atom.make((get): ReadonlyMap<ThreadId, OrchestrationV2ThreadShell> => {
47 const threads = get(environmentThreadsAtom(environmentId));47 const threads = get(environmentThreadsAtom(environmentId));
48 if (threads.length === 0) {48 if (threads.length === 0) {
49 return EMPTY_THREAD_INDEX;49 return EMPTY_THREAD_INDEX;
@@ -102,7 +102,7 @@ export function createEnvironmentThreadShellAtoms(input: { · 52 unmodified lines
102 102
103 const threadShellAtomFamily = Atom.family((key: string) => {103 const threadShellAtomFamily = Atom.family((key: string) => {
104 const ref = parseThreadKey(key);104 const ref = parseThreadKey(key);
105 let previousSource: OrchestrationThreadShell | null = null;105 let previousSource: OrchestrationV2ThreadShell | null = null;
106 let previousValue: EnvironmentThreadShell | null = null;106 let previousValue: EnvironmentThreadShell | null = null;
107 return Atom.make((get) => {107 return Atom.make((get) => {
108 const source = get(environmentThreadIndexAtom(ref.environmentId)).get(ref.threadId) ?? null;108 const source = get(environmentThreadIndexAtom(ref.environmentId)).get(ref.threadId) ?? null;
@@ -110,7 +110,7 @@ export function createEnvironmentThreadShellAtoms(input: { · 1 unmodified line
110 return previousValue;110 return previousValue;
111 }111 }
112 previousSource = source;112 previousSource = source;
113 previousValue = source === null ? null : scopeThreadShell(ref.environmentId, source);113 previousValue = source === null ? null : presentThreadShell(ref.environmentId, source);
114 return previousValue;114 return previousValue;
115 }).pipe(Atom.withLabel(`environment-thread-shell:${key}`));115 }).pipe(Atom.withLabel(`environment-thread-shell:${key}`));
116 });116 });
packages/client-runtime/src/state/threads-sync.test.ts+64−562
@@ -1,41 +1,29 @@
1import {1import {
2 EnvironmentId,2 EnvironmentId,
3 EventId,3 EventId,
4 ORCHESTRATION_WS_METHODS,4 ORCHESTRATION_V2_WS_METHODS,
5 ProjectId,5 type OrchestrationV2ThreadStreamItem,
6 ProviderInstanceId,
7 ThreadId,
8 TurnId,
9 type OrchestrationThread,
10 type OrchestrationThreadDetailSnapshot,
11 type OrchestrationThreadStreamItem,
12} from "@t3tools/contracts";6} from "@t3tools/contracts";
13import { describe, expect, it } from "@effect/vitest";7import { describe, expect, it } from "@effect/vitest";
8import * as DateTime from "effect/DateTime";
14import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
15import * as Option from "effect/Option";10import * as Option from "effect/Option";
16import * as Queue from "effect/Queue";11import * as Queue from "effect/Queue";
17import * as Ref from "effect/Ref";
18import * as Stream from "effect/Stream";12import * as Stream from "effect/Stream";
19import * as SubscriptionRef from "effect/SubscriptionRef";13import * as SubscriptionRef from "effect/SubscriptionRef";
20import * as TestClock from "effect/testing/TestClock";
21 14
22import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
23import {15import {
24 AVAILABLE_CONNECTION_STATE,16 AVAILABLE_CONNECTION_STATE,
25 PrimaryConnectionTarget,17 PrimaryConnectionTarget,
26 type PreparedConnection,18 type PreparedConnection,
27 type SupervisorConnectionState,
28} from "../connection/model.ts";19} from "../connection/model.ts";
29import * as ConnectionWakeups from "../connection/wakeups.ts";20import * as ConnectionWakeups from "../connection/wakeups.ts";
30import * as EnvironmentSupervisor from "../connection/supervisor.ts";21import * as EnvironmentSupervisor from "../connection/supervisor.ts";
31import * as Persistence from "../platform/persistence.ts";22import * as Persistence from "../platform/persistence.ts";
32import * as RpcSession from "../rpc/session.ts";23import * as RpcSession from "../rpc/session.ts";
33import {24import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
34 EMPTY_ENVIRONMENT_THREAD_STATE,25import { v2Projection, v2ThreadId } from "./orchestrationV2TestFixtures.ts";
35 makeEnvironmentThreadState,26import { makeEnvironmentThreadState } from "./threads.ts";
36 ThreadSnapshotLoader,
37 type EnvironmentThreadState,
38} from "./threads.ts";
39 27
40const TARGET = new PrimaryConnectionTarget({28const TARGET = new PrimaryConnectionTarget({
41 environmentId: EnvironmentId.make("environment-1"),29 environmentId: EnvironmentId.make("environment-1"),
@@ -43,559 +31,73 @@ const TARGET = new PrimaryConnectionTarget({ · 1 unmodified line
43 httpBaseUrl: "https://environment.example.test",31 httpBaseUrl: "https://environment.example.test",
44 wsBaseUrl: "wss://environment.example.test",32 wsBaseUrl: "wss://environment.example.test",
45});33});
46const THREAD_ID = ThreadId.make("thread-1");
47const CACHED_SNAPSHOT_SEQUENCE = 7;
48const 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};
56const 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};
81const 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
102type TestThreadInput = OrchestrationThreadStreamItem | Error;
103
104function 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
121function 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
132const 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
266const snapshot = (thread: OrchestrationThread): OrchestrationThreadStreamItem => ({
267 kind: "snapshot",
268 snapshot: {
269 snapshotSequence: 1,
270 thread,
271 },
272});
273
274const synchronized = (): OrchestrationThreadStreamItem => ({ kind: "synchronized" });
275
276const 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
297const 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
317describe("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 );
342 34
343 // The subscription resumed from the cached sequence and never fetched the35describe("V2 thread synchronization", () => {
344 // full snapshot over HTTP.36 it.effect("applies a snapshot followed by committed projection events", () =>
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", () =>
398 Effect.gen(function* () {37 Effect.gen(function* () {
399 const httpThread: OrchestrationThread = { ...BASE_THREAD, title: "HTTP title" };38 const events = yield* Queue.unbounded<OrchestrationV2ThreadStreamItem>();
400 const harness = yield* makeHarness({39 const client = {
401 httpSnapshot: Option.some({ snapshotSequence: 1, thread: httpThread }),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,
402 });57 });
403 // No socket snapshot is pushed; only a live event arrives over the socket.58 const cache = Persistence.EnvironmentCacheStore.of({
404 // It can only be applied if the HTTP snapshot already seeded the thread.59 loadShell: () => Effect.succeed(Option.none()),
405 yield* Queue.offer(harness.inputs, titleUpdated("Live title", 2));60 saveShell: () => Effect.void,
406 61 loadThread: () => Effect.succeed(Option.none()),
407 const state = yield* awaitThreadState(62 saveThread: () => Effect.void,
408 harness.observed,63 removeThread: () => Effect.void,
409 (value) =>64 clear: () => Effect.void,
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 }),
467 });65 });
468 yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD));66 const state = yield* makeEnvironmentThreadState(v2ThreadId).pipe(
469 yield* Queue.offer(harness.inputs, deleted());67 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
470 yield* awaitThreadState(harness.observed, (value) => value.status === "deleted");68 Effect.provideService(Persistence.EnvironmentCacheStore, cache),
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",
560 );69 );
561 70
562 expect(Option.isNone(recovered.error)).toBe(true);71 yield* Queue.offer(events, {
563 expect(yield* Ref.get(harness.subscriptionCount)).toBe(2);72 kind: "snapshot",
564 expect(yield* Ref.get(harness.retryCount)).toBe(0);73 snapshotSequence: 1,
565 }),74 projection: v2Projection,
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,
580 });75 });
581 yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD));76 yield* SubscriptionRef.changes(state).pipe(
582 yield* awaitThreadState(harness.observed, (value) => value.status === "live");77 Stream.filter((current) => current.status === "live"),
583 78 Stream.runHead,
584 yield* SubscriptionRef.set(harness.supervisorState, {79 );
585 desired: true,80
586 network: "online",81 const occurredAt = DateTime.makeUnsafe("2026-06-20T02:00:00.000Z");
587 phase: "connected",82 yield* Queue.offer(events, {
588 stage: null,83 kind: "event",
589 attempt: 1,84 sequence: 2,
590 generation: 1,85 event: {
591 lastFailure: null,86 id: EventId.make("event-title"),
592 retryAt: null,87 type: "thread.metadata-updated",
88 threadId: v2ThreadId,
89 occurredAt,
90 payload: { ...v2Projection.thread, title: "Updated", updatedAt: occurredAt },
91 },
593 });92 });
594 for (let index = 0; index < 10; index += 1) {93 yield* SubscriptionRef.changes(state).pipe(
595 yield* Effect.yieldNow;94 Stream.filter((current) => Option.getOrNull(current.data)?.thread.title === "Updated"),
596 }95 Stream.runHead,
96 );
597 97
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 );
599 }),101 }),
600 );102 );
601 103
packages/client-runtime/src/state/threads.ts+45−88
@@ -1,9 +1,8 @@
1import {1import {
2 ORCHESTRATION_WS_METHODS,2 ORCHESTRATION_V2_WS_METHODS,
3 type EnvironmentId as EnvironmentIdType,3 type EnvironmentId as EnvironmentIdType,
4 type OrchestrationThread,4 type OrchestrationV2ThreadProjection,
5 type OrchestrationThreadDetailSnapshot,5 type OrchestrationV2ThreadStreamItem,
6 type OrchestrationThreadStreamItem,
7 type ThreadId as ThreadIdType,6 type ThreadId as ThreadIdType,
8} from "@t3tools/contracts";7} from "@t3tools/contracts";
9import * as Cause from "effect/Cause";8import * as Cause from "effect/Cause";
@@ -23,16 +22,27 @@ import { EnvironmentCacheStore } from "../platform/persistence.ts"; · 13 unmodified lines
23import { subscribeDynamic } from "../rpc/client.ts";22import { subscribeDynamic } from "../rpc/client.ts";
24import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts";23import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts";
25import { parseThreadKey, threadKey } from "./entities.ts";24import { parseThreadKey, threadKey } from "./entities.ts";
26import { applyThreadDetailEvent } from "./threadReducer.ts";25import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection.ts";
27import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";26import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
28import { followStreamInEnvironment } from "./runtime.ts";27import { followStreamInEnvironment } from "./runtime.ts";
29import {
30 EMPTY_ENVIRONMENT_THREAD_STATE,
31 type EnvironmentThreadState,
32 type EnvironmentThreadStatus,
33} from "./threadState.ts";
34 28
35function statusWithoutLiveData(data: Option.Option<OrchestrationThread>): EnvironmentThreadStatus {29export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted";
30
31export interface EnvironmentThreadState {
32 readonly data: Option.Option<OrchestrationV2ThreadProjection>;
33 readonly status: EnvironmentThreadStatus;
34 readonly error: Option.Option<string>;
35}
36
37export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = {
38 data: Option.none(),
39 status: "empty",
40 error: Option.none(),
41};
42
43function statusWithoutLiveData(
44 data: Option.Option<OrchestrationV2ThreadProjection>,
45): EnvironmentThreadStatus {
36 return Option.isSome(data) ? "cached" : "empty";46 return Option.isSome(data) ? "cached" : "empty";
37}47}
38 48
@@ -64,7 +74,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 25 unmodified lines
64 threadId,74 threadId,
65 error: error.message,75 error: error.message,
66 }),76 }),
67 Effect.as(Option.none<OrchestrationThreadDetailSnapshot>()),77 Effect.as(Option.none<OrchestrationV2ThreadProjection>()),
68 ),78 ),
69 ),79 ),
70 );80 );
@@ -74,16 +84,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 3 unmodified lines
74 status: statusWithoutLiveData(cachedThread),84 status: statusWithoutLiveData(cachedThread),
75 error: Option.none(),85 error: Option.none(),
76 });86 });
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.