@stigmer/react 3.1.4 → 3.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/execution/FileReviewContext.d.ts +15 -1
  2. package/execution/FileReviewContext.d.ts.map +1 -1
  3. package/execution/FileReviewContext.js +20 -1
  4. package/execution/FileReviewContext.js.map +1 -1
  5. package/execution/MessageThread.d.ts +65 -2
  6. package/execution/MessageThread.d.ts.map +1 -1
  7. package/execution/MessageThread.js +42 -23
  8. package/execution/MessageThread.js.map +1 -1
  9. package/execution/TodoCard.d.ts +8 -0
  10. package/execution/TodoCard.d.ts.map +1 -1
  11. package/execution/TodoCard.js +6 -4
  12. package/execution/TodoCard.js.map +1 -1
  13. package/execution/TodoList.d.ts +20 -1
  14. package/execution/TodoList.d.ts.map +1 -1
  15. package/execution/TodoList.js +9 -6
  16. package/execution/TodoList.js.map +1 -1
  17. package/execution/ToolCallDetail.d.ts +3 -2
  18. package/execution/ToolCallDetail.d.ts.map +1 -1
  19. package/execution/ToolCallDetail.js +49 -16
  20. package/execution/ToolCallDetail.js.map +1 -1
  21. package/execution/file-review-status.d.ts +15 -0
  22. package/execution/file-review-status.d.ts.map +1 -1
  23. package/execution/file-review-status.js +22 -0
  24. package/execution/file-review-status.js.map +1 -1
  25. package/execution/index.d.ts +5 -5
  26. package/execution/index.d.ts.map +1 -1
  27. package/execution/index.js +3 -3
  28. package/execution/index.js.map +1 -1
  29. package/github/useGitHubFileReader.d.ts +4 -2
  30. package/github/useGitHubFileReader.d.ts.map +1 -1
  31. package/github/useGitHubFileReader.js +16 -3
  32. package/github/useGitHubFileReader.js.map +1 -1
  33. package/github/useGitHubTreeLister.d.ts +2 -1
  34. package/github/useGitHubTreeLister.d.ts.map +1 -1
  35. package/github/useGitHubTreeLister.js +7 -3
  36. package/github/useGitHubTreeLister.js.map +1 -1
  37. package/index.d.ts +3 -3
  38. package/index.d.ts.map +1 -1
  39. package/index.js +3 -3
  40. package/index.js.map +1 -1
  41. package/internal/VirtualizedThread.d.ts +3 -2
  42. package/internal/VirtualizedThread.d.ts.map +1 -1
  43. package/internal/VirtualizedThread.js +3 -2
  44. package/internal/VirtualizedThread.js.map +1 -1
  45. package/package.json +4 -4
  46. package/session/SessionViewer.d.ts +11 -1
  47. package/session/SessionViewer.d.ts.map +1 -1
  48. package/session/SessionViewer.js +12 -5
  49. package/session/SessionViewer.js.map +1 -1
  50. package/session/index.d.ts +1 -0
  51. package/session/index.d.ts.map +1 -1
  52. package/session/index.js +1 -0
  53. package/session/index.js.map +1 -1
  54. package/session/useWorkspaceReadRefs.d.ts +45 -0
  55. package/session/useWorkspaceReadRefs.d.ts.map +1 -0
  56. package/session/useWorkspaceReadRefs.js +104 -0
  57. package/session/useWorkspaceReadRefs.js.map +1 -0
  58. package/src/execution/FileReviewContext.ts +27 -1
  59. package/src/execution/MessageThread.tsx +110 -22
  60. package/src/execution/TodoCard.tsx +20 -4
  61. package/src/execution/TodoList.tsx +26 -4
  62. package/src/execution/ToolCallDetail.tsx +69 -16
  63. package/src/execution/__tests__/ToolCallDetail.test.tsx +188 -2
  64. package/src/execution/__tests__/file-review-status.test.ts +47 -0
  65. package/src/execution/__tests__/message-thread-slots.test.tsx +463 -0
  66. package/src/execution/file-review-status.ts +24 -0
  67. package/src/execution/index.ts +13 -3
  68. package/src/github/__tests__/useGitHubFileReader.test.ts +35 -2
  69. package/src/github/__tests__/useGitHubTreeLister.test.ts +14 -0
  70. package/src/github/useGitHubFileReader.ts +19 -6
  71. package/src/github/useGitHubTreeLister.ts +7 -3
  72. package/src/index.ts +4 -0
  73. package/src/internal/VirtualizedThread.tsx +5 -1
  74. package/src/session/SessionViewer.tsx +27 -2
  75. package/src/session/__tests__/useWorkspaceReadRefs.test.ts +245 -0
  76. package/src/session/index.ts +2 -0
  77. package/src/session/useWorkspaceReadRefs.ts +118 -0
  78. package/src/workspace/FileViewer.tsx +144 -38
  79. package/src/workspace/WorkspaceFileReader.ts +21 -0
  80. package/src/workspace/__tests__/FileViewer.test.tsx +138 -3
  81. package/src/workspace/__tests__/useWorkspaceFileContent.test.tsx +20 -0
  82. package/src/workspace/__tests__/workspaceListingCache.test.ts +114 -0
  83. package/src/workspace/index.ts +4 -1
  84. package/src/workspace/useWorkspaceEntries.ts +11 -0
  85. package/src/workspace/useWorkspaceFileContent.ts +4 -1
  86. package/src/workspace/useWorkspaceFileSearch.ts +8 -3
  87. package/src/workspace/useWorkspaceFiles.ts +3 -2
  88. package/src/workspace/workspaceListingCache.ts +21 -7
  89. package/styles.css +1 -1
  90. package/workspace/FileViewer.d.ts +1 -1
  91. package/workspace/FileViewer.d.ts.map +1 -1
  92. package/workspace/FileViewer.js +55 -5
  93. package/workspace/FileViewer.js.map +1 -1
  94. package/workspace/WorkspaceFileReader.d.ts +17 -0
  95. package/workspace/WorkspaceFileReader.d.ts.map +1 -1
  96. package/workspace/WorkspaceFileReader.js +18 -0
  97. package/workspace/WorkspaceFileReader.js.map +1 -1
  98. package/workspace/index.d.ts +1 -1
  99. package/workspace/index.d.ts.map +1 -1
  100. package/workspace/index.js +1 -1
  101. package/workspace/index.js.map +1 -1
  102. package/workspace/useWorkspaceEntries.d.ts +11 -0
  103. package/workspace/useWorkspaceEntries.d.ts.map +1 -1
  104. package/workspace/useWorkspaceEntries.js.map +1 -1
  105. package/workspace/useWorkspaceFileContent.d.ts.map +1 -1
  106. package/workspace/useWorkspaceFileContent.js +4 -1
  107. package/workspace/useWorkspaceFileContent.js.map +1 -1
  108. package/workspace/useWorkspaceFileSearch.d.ts.map +1 -1
  109. package/workspace/useWorkspaceFileSearch.js +8 -3
  110. package/workspace/useWorkspaceFileSearch.js.map +1 -1
  111. package/workspace/useWorkspaceFiles.d.ts +2 -1
  112. package/workspace/useWorkspaceFiles.d.ts.map +1 -1
  113. package/workspace/useWorkspaceFiles.js +3 -2
  114. package/workspace/useWorkspaceFiles.js.map +1 -1
  115. package/workspace/workspaceListingCache.d.ts +1 -1
  116. package/workspace/workspaceListingCache.d.ts.map +1 -1
  117. package/workspace/workspaceListingCache.js +20 -7
  118. package/workspace/workspaceListingCache.js.map +1 -1
@@ -38,7 +38,8 @@ const TRUNCATION_MARKER: WorkspaceFileEntry = {
38
38
  * Creates a {@link WorkspaceFileLister} backed by the GitHub Trees API.
39
39
  *
40
40
  * Uses the existing client-side OAuth token (same one that powers
41
- * `GitHubRepoPicker`) to call `GET /repos/{owner}/{repo}/git/trees/{branch}?recursive=1`.
41
+ * `GitHubRepoPicker`) to call `GET /repos/{owner}/{repo}/git/trees/{ref}?recursive=1`,
42
+ * where the ref is the entry's `readRef` (write-back commit) or `gitBranch`.
42
43
  *
43
44
  * Returns `null` for:
44
45
  * - Non-git workspace entries
@@ -64,8 +65,11 @@ export function useGitHubTreeLister(
64
65
  const parsed = parseGitUrl(entry.gitUrl);
65
66
  if (!parsed) return null;
66
67
 
67
- const branch = entry.gitBranch || "main";
68
- const url = `${GITHUB_TREES_API}/${parsed.owner}/${parsed.repo}/git/trees/${encodeURIComponent(branch)}?recursive=1`;
68
+ // readRef (the session's write-back commit SHA, when one exists) wins
69
+ // over the configured branch, so the tree includes agent-created files.
70
+ // The Trees API accepts a branch name or a commit SHA interchangeably.
71
+ const ref = entry.readRef || entry.gitBranch || "main";
72
+ const url = `${GITHUB_TREES_API}/${parsed.owner}/${parsed.repo}/git/trees/${encodeURIComponent(ref)}?recursive=1`;
69
73
 
70
74
  const resp = await fetch(url, {
71
75
  headers: { Authorization: `Bearer ${token}` },
package/src/index.ts CHANGED
@@ -71,6 +71,7 @@ export {
71
71
  useWorkspaceContentSearch,
72
72
  useWorkspaceSources,
73
73
  MAX_WORKSPACE_FILE_READ_BYTES,
74
+ WorkspaceFileNotFoundError,
74
75
  WorkspaceEditor,
75
76
  WorkspaceFileSearch,
76
77
  WorkspaceContentSearch,
@@ -131,6 +132,7 @@ export {
131
132
  useSessionArtifacts,
132
133
  artifactKey,
133
134
  useSessionWriteBacks,
135
+ useWorkspaceReadRefs,
134
136
  useSessionFileChanges,
135
137
  useSessionUsage,
136
138
  useAgentRefFromSession,
@@ -233,6 +235,7 @@ export {
233
235
  deriveEffectiveVerdicts,
234
236
  changeForRowPath,
235
237
  fileReviewRowState,
238
+ fileReviewRowChange,
236
239
  foldFileReviewEventStream,
237
240
  displayFileChangeSets,
238
241
  toDisplayFileChange,
@@ -290,6 +293,7 @@ export {
290
293
  useApproval,
291
294
  FileReviewContext,
292
295
  useFileReviewRowState,
296
+ useFileReviewRowChange,
293
297
  ArtifactRow,
294
298
  ArtifactContentRenderer,
295
299
  ArtifactContentBody,
@@ -15,6 +15,7 @@ import { ApprovalAction } from "@stigmer/protos/ai/stigmer/agentic/agentexecutio
15
15
  import {
16
16
  ThreadItemRenderer,
17
17
  threadContentColumnClass,
18
+ type MessageThreadSlots,
18
19
  type ThreadItem,
19
20
  type ThreadContentColumn,
20
21
  } from "../execution/MessageThread.js";
@@ -55,6 +56,7 @@ export interface VirtualizedThreadProps {
55
56
  readonly onRetrySend?: () => void;
56
57
  readonly onRetryExecution?: (message: string) => void;
57
58
  readonly onEditMessage?: (text: string) => void;
59
+ readonly slots?: MessageThreadSlots;
58
60
  }
59
61
 
60
62
  // ---------------------------------------------------------------------------
@@ -124,6 +126,7 @@ export function VirtualizedThread({
124
126
  onRetrySend,
125
127
  onRetryExecution,
126
128
  onEditMessage,
129
+ slots,
127
130
  }: VirtualizedThreadProps) {
128
131
  const virtuosoRef = useRef<VirtuosoHandle>(null);
129
132
  const scrollerRef = useRef<HTMLDivElement | null>(null);
@@ -161,8 +164,9 @@ export function VirtualizedThread({
161
164
  onRetrySend,
162
165
  onRetryExecution,
163
166
  onEditMessage,
167
+ slots,
164
168
  }),
165
- [formatToolCallSummary, onApprovalSubmit, submittingApprovalIds, approvalErrors, onBuildFromPlan, onOpenPlan, org, planActionsDisabled, planBuildPending, onRetrySend, onRetryExecution, onEditMessage],
169
+ [formatToolCallSummary, onApprovalSubmit, submittingApprovalIds, approvalErrors, onBuildFromPlan, onOpenPlan, org, planActionsDisabled, planBuildPending, onRetrySend, onRetryExecution, onEditMessage, slots],
166
170
  );
167
171
 
168
172
  return (
@@ -24,7 +24,7 @@ import { SelectionStore } from "../internal/store/selection-store.js";
24
24
  import { useWorkspaceEditors, isVirtualEntryId } from "../internal/store/index.js";
25
25
  import { ThreadSelectionContext } from "../execution/ThreadSelectionContext.js";
26
26
  import { useSelectedThreadItem } from "../execution/useThreadSelection.js";
27
- import { MessageThread } from "../execution/MessageThread.js";
27
+ import { MessageThread, type MessageThreadSlots } from "../execution/MessageThread.js";
28
28
  import { FileChangeProgressBar } from "../execution/FileChangeProgressBar.js";
29
29
  import { FileReviewDock } from "../execution/FileReviewDock.js";
30
30
  import {
@@ -53,6 +53,7 @@ import { useSessionRailViews } from "./useSessionRailViews.js";
53
53
  import { useSessionPanel, type SessionPanelController } from "./useSessionPanel.js";
54
54
  import { SessionPanelChip } from "./SessionPanelChip.js";
55
55
  import { useSessionWriteBacks } from "./useSessionWriteBacks.js";
56
+ import { useWorkspaceReadRefs } from "./useWorkspaceReadRefs.js";
56
57
  import {
57
58
  useSessionArtifacts,
58
59
  artifactKey,
@@ -228,6 +229,15 @@ export interface SessionViewerProps {
228
229
  readonly accessSlot?: ReactNode;
229
230
  /** Called after a resource is applied from the Artifacts tab. */
230
231
  readonly onApplied?: (result: ApplyResourceResult) => void;
232
+ /**
233
+ * Component overrides for the conversation thread's chrome (message
234
+ * bubbles, approval card, to-dos card, plan cards, thinking indicator) —
235
+ * forwarded to the inner {@link MessageThread}. See
236
+ * {@link MessageThreadSlots}. Define the object and its components at
237
+ * module level (or memoize) so thread rows keep their streaming
238
+ * re-render guarantees.
239
+ */
240
+ readonly threadSlots?: MessageThreadSlots;
231
241
  /** Additional CSS classes for the root container. */
232
242
  readonly className?: string;
233
243
  }
@@ -289,6 +299,7 @@ export function SessionViewer({
289
299
  headerActions,
290
300
  accessSlot,
291
301
  onApplied,
302
+ threadSlots,
292
303
  className,
293
304
  }: SessionViewerProps) {
294
305
  const flow = useSessionPageFlow({ sessionId, org, getRuntimeEnv });
@@ -572,6 +583,7 @@ export function SessionViewer({
572
583
  onDismissPlanAttachFailed={() => setPlanAttachFailed(false)}
573
584
  onFilePathClick={handleTranscriptFilePathClick}
574
585
  isEndUser={isEndUser}
586
+ threadSlots={threadSlots}
575
587
  />
576
588
  }
577
589
  secondary={
@@ -637,6 +649,7 @@ interface ConversationColumnProps {
637
649
  */
638
650
  readonly onFilePathClick: (path: string) => boolean;
639
651
  readonly isEndUser: boolean;
652
+ readonly threadSlots?: MessageThreadSlots;
640
653
  }
641
654
 
642
655
  const ConversationColumn = memo(function ConversationColumn({
@@ -658,6 +671,7 @@ const ConversationColumn = memo(function ConversationColumn({
658
671
  onDismissPlanAttachFailed,
659
672
  onFilePathClick,
660
673
  isEndUser,
674
+ threadSlots,
661
675
  }: ConversationColumnProps) {
662
676
  const { conv } = flow;
663
677
  // Approval failures are NOT folded into this banner: they now surface in-card,
@@ -770,6 +784,7 @@ const ConversationColumn = memo(function ConversationColumn({
770
784
  planActionsDisabled={!conv.canSendFollowUp || isBuildingFromPlan}
771
785
  planBuildPending={isBuildingFromPlan}
772
786
  contentColumn="center"
787
+ slots={threadSlots}
773
788
  className="flex-1"
774
789
  />
775
790
  <div className={CONVERSATION_COLUMN_CLASS}>
@@ -1133,9 +1148,19 @@ function SessionPanelRegion({
1133
1148
  if (path) flow.workspace.addLocalPath(path);
1134
1149
  }, [onBrowseLocalFolder, flow.workspace.addLocalPath]);
1135
1150
 
1151
+ // Entries decorated with per-entry read refs (the latest write-back commit
1152
+ // SHA), so the surface's read-side capabilities — file viewer, tree listing,
1153
+ // name search — resolve at the ref the agent's changes actually live on. A
1154
+ // read-side projection only: `flow.workspace.entries` stays undecorated for
1155
+ // setup and session input.
1156
+ const surfaceEntries = useWorkspaceReadRefs(
1157
+ flow.allExecutions,
1158
+ flow.workspace.entries,
1159
+ );
1160
+
1136
1161
  return (
1137
1162
  <WorkspaceSurface
1138
- entries={flow.workspace.entries}
1163
+ entries={surfaceEntries}
1139
1164
  lister={workspaceFileLister}
1140
1165
  reader={workspaceFileReader}
1141
1166
  searcher={workspaceContentSearcher}
@@ -0,0 +1,245 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderHook } from "@testing-library/react";
3
+ import { create } from "@bufbuild/protobuf";
4
+ import {
5
+ AgentExecutionSchema,
6
+ AgentExecutionStatusSchema,
7
+ type AgentExecution,
8
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
9
+ import { ApiResourceMetadataSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/metadata_pb";
10
+ import {
11
+ WorkspaceWriteBackSchema,
12
+ WorkspaceWriteBackPhase,
13
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/writeback_pb";
14
+ import type { WorkspaceEntry } from "../../workspace/useWorkspaceEntries";
15
+ import { useWorkspaceReadRefs } from "../useWorkspaceReadRefs";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Fixtures
19
+ // ---------------------------------------------------------------------------
20
+
21
+ function gitEntry(overrides?: Partial<WorkspaceEntry>): WorkspaceEntry {
22
+ return {
23
+ id: "ws-1",
24
+ name: "acme/api",
25
+ type: "git",
26
+ gitUrl: "https://github.com/acme/api",
27
+ gitBranch: "main",
28
+ ...overrides,
29
+ };
30
+ }
31
+
32
+ function localEntry(): WorkspaceEntry {
33
+ return {
34
+ id: "ws-local",
35
+ name: "dev/project",
36
+ type: "local",
37
+ localPath: "/Users/dev/project",
38
+ };
39
+ }
40
+
41
+ function execWithWriteBacks(
42
+ id: string,
43
+ writeBacks: ReadonlyArray<{
44
+ entryName: string;
45
+ commitSha: string;
46
+ phase?: WorkspaceWriteBackPhase;
47
+ }>,
48
+ ): AgentExecution {
49
+ const exec = create(AgentExecutionSchema);
50
+ exec.metadata = create(ApiResourceMetadataSchema, { id });
51
+ exec.status = create(AgentExecutionStatusSchema);
52
+ exec.status.workspaceWriteBacks = writeBacks.map((wb) =>
53
+ create(WorkspaceWriteBackSchema, {
54
+ workspaceEntryName: wb.entryName,
55
+ commitSha: wb.commitSha,
56
+ phase: wb.phase ?? WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PUSHED,
57
+ }),
58
+ );
59
+ return exec;
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // useWorkspaceReadRefs
64
+ // ---------------------------------------------------------------------------
65
+
66
+ describe("useWorkspaceReadRefs", () => {
67
+ it("returns the same entries reference when there are no write-backs", () => {
68
+ const entries = [gitEntry()];
69
+ const { result } = renderHook(() => useWorkspaceReadRefs([], entries));
70
+ expect(result.current).toBe(entries);
71
+ });
72
+
73
+ it("decorates a single git entry from a write-back with a matching name", () => {
74
+ const entries = [gitEntry({ name: "acme/api" })];
75
+ const executions = [
76
+ execWithWriteBacks("e1", [{ entryName: "acme/api", commitSha: "sha-1" }]),
77
+ ];
78
+
79
+ const { result } = renderHook(() =>
80
+ useWorkspaceReadRefs(executions, entries),
81
+ );
82
+
83
+ expect(result.current[0].readRef).toBe("sha-1");
84
+ });
85
+
86
+ it("applies an empty-name write-back to the lone git entry (single-entry convention)", () => {
87
+ // Single-entry sessions provision with entryName "" — the runner's
88
+ // WriteBackCoordinator writes back under that empty name.
89
+ const entries = [gitEntry()];
90
+ const executions = [
91
+ execWithWriteBacks("e1", [{ entryName: "", commitSha: "sha-solo" }]),
92
+ ];
93
+
94
+ const { result } = renderHook(() =>
95
+ useWorkspaceReadRefs(executions, entries),
96
+ );
97
+
98
+ expect(result.current[0].readRef).toBe("sha-solo");
99
+ });
100
+
101
+ it("claims a lone mismatched name for a lone git entry, but never for multiple entries", () => {
102
+ const solo = renderHook(() =>
103
+ useWorkspaceReadRefs(
104
+ [execWithWriteBacks("e1", [{ entryName: "other-name", commitSha: "sha-x" }])],
105
+ [gitEntry()],
106
+ ),
107
+ );
108
+ expect(solo.result.current[0].readRef).toBe("sha-x");
109
+
110
+ // With two git entries an unmatched name is ambiguous — decorate nothing.
111
+ const multi = renderHook(() =>
112
+ useWorkspaceReadRefs(
113
+ [execWithWriteBacks("e1", [{ entryName: "other-name", commitSha: "sha-x" }])],
114
+ [
115
+ gitEntry({ id: "ws-1", name: "acme/api" }),
116
+ gitEntry({ id: "ws-2", name: "acme/web" }),
117
+ ],
118
+ ),
119
+ );
120
+ expect(multi.result.current[0].readRef).toBeUndefined();
121
+ expect(multi.result.current[1].readRef).toBeUndefined();
122
+ });
123
+
124
+ it("the latest non-empty SHA wins across executions in order", () => {
125
+ const entries = [gitEntry({ name: "acme/api" })];
126
+ const executions = [
127
+ execWithWriteBacks("e1", [{ entryName: "acme/api", commitSha: "sha-old" }]),
128
+ execWithWriteBacks("e2", [{ entryName: "acme/api", commitSha: "sha-new" }]),
129
+ ];
130
+
131
+ const { result } = renderHook(() =>
132
+ useWorkspaceReadRefs(executions, entries),
133
+ );
134
+
135
+ expect(result.current[0].readRef).toBe("sha-new");
136
+ });
137
+
138
+ it("a FAILED write-back with no SHA never regresses an earlier successful ref", () => {
139
+ const entries = [gitEntry({ name: "acme/api" })];
140
+ const executions = [
141
+ execWithWriteBacks("e1", [{ entryName: "acme/api", commitSha: "sha-good" }]),
142
+ execWithWriteBacks("e2", [
143
+ {
144
+ entryName: "acme/api",
145
+ commitSha: "",
146
+ phase: WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_FAILED,
147
+ },
148
+ ]),
149
+ ];
150
+
151
+ const { result } = renderHook(() =>
152
+ useWorkspaceReadRefs(executions, entries),
153
+ );
154
+
155
+ expect(result.current[0].readRef).toBe("sha-good");
156
+ });
157
+
158
+ it("returns entries unchanged when only FAILED no-SHA write-backs exist", () => {
159
+ const entries = [gitEntry()];
160
+ const executions = [
161
+ execWithWriteBacks("e1", [
162
+ {
163
+ entryName: "",
164
+ commitSha: "",
165
+ phase: WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_FAILED,
166
+ },
167
+ ]),
168
+ ];
169
+
170
+ const { result } = renderHook(() =>
171
+ useWorkspaceReadRefs(executions, entries),
172
+ );
173
+
174
+ expect(result.current).toBe(entries);
175
+ });
176
+
177
+ it("matches strictly by name in multi-entry sessions", () => {
178
+ const entries = [
179
+ gitEntry({ id: "ws-1", name: "frontend" }),
180
+ gitEntry({ id: "ws-2", name: "backend" }),
181
+ ];
182
+ const executions = [
183
+ execWithWriteBacks("e1", [
184
+ { entryName: "frontend", commitSha: "sha-fe" },
185
+ { entryName: "backend", commitSha: "sha-be" },
186
+ ]),
187
+ ];
188
+
189
+ const { result } = renderHook(() =>
190
+ useWorkspaceReadRefs(executions, entries),
191
+ );
192
+
193
+ expect(result.current[0].readRef).toBe("sha-fe");
194
+ expect(result.current[1].readRef).toBe("sha-be");
195
+ });
196
+
197
+ it("never decorates local entries", () => {
198
+ const entries = [localEntry()];
199
+ const executions = [
200
+ execWithWriteBacks("e1", [{ entryName: "", commitSha: "sha-1" }]),
201
+ ];
202
+
203
+ const { result } = renderHook(() =>
204
+ useWorkspaceReadRefs(executions, entries),
205
+ );
206
+
207
+ expect(result.current[0].readRef).toBeUndefined();
208
+ expect(result.current).toBe(entries);
209
+ });
210
+
211
+ it("returns a stable array across streaming frames that change no SHA (DD-010)", () => {
212
+ const entries = [gitEntry({ name: "acme/api" })];
213
+ const { result, rerender } = renderHook(
214
+ (props: { executions: readonly AgentExecution[] }) =>
215
+ useWorkspaceReadRefs(props.executions, entries),
216
+ {
217
+ initialProps: {
218
+ executions: [
219
+ execWithWriteBacks("e1", [{ entryName: "acme/api", commitSha: "sha-1" }]),
220
+ ] as readonly AgentExecution[],
221
+ },
222
+ },
223
+ );
224
+
225
+ const first = result.current;
226
+ expect(first[0].readRef).toBe("sha-1");
227
+
228
+ // A new executions array (fresh identity, e.g. a streaming frame) with the
229
+ // same SHA must return the identical decorated array.
230
+ rerender({
231
+ executions: [
232
+ execWithWriteBacks("e1", [{ entryName: "acme/api", commitSha: "sha-1" }]),
233
+ ],
234
+ });
235
+ expect(result.current).toBe(first);
236
+
237
+ // Advancing the SHA produces a new decoration.
238
+ rerender({
239
+ executions: [
240
+ execWithWriteBacks("e1", [{ entryName: "acme/api", commitSha: "sha-2" }]),
241
+ ],
242
+ });
243
+ expect(result.current[0].readRef).toBe("sha-2");
244
+ });
245
+ });
@@ -45,6 +45,8 @@ export type {
45
45
  UseSessionWriteBacksReturn,
46
46
  } from "./useSessionWriteBacks.js";
47
47
 
48
+ export { useWorkspaceReadRefs } from "./useWorkspaceReadRefs.js";
49
+
48
50
  export { useSessionFileChanges } from "./useSessionFileChanges.js";
49
51
  export type { UseSessionFileChangesReturn } from "./useSessionFileChanges.js";
50
52
 
@@ -0,0 +1,118 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
5
+ import type { WorkspaceEntry } from "../workspace/useWorkspaceEntries.js";
6
+
7
+ /**
8
+ * Pure derivation hook that projects the session's workspace write-backs onto
9
+ * its workspace entries as per-entry read refs.
10
+ *
11
+ * The agent's file changes land on a write-back branch (`stigmer/<id>`), not
12
+ * the branch the session was configured with — so read-side surfaces (file
13
+ * viewer, tree listing, name search) pointed at the configured branch would
14
+ * 404 on agent-created files and silently show stale content for modified
15
+ * ones. This hook returns the entries with `readRef` set to the latest
16
+ * write-back commit SHA, which those surfaces resolve in preference to
17
+ * `gitBranch`. A commit SHA (not the branch name) so reads are immutable-ref
18
+ * exact, immune to branch eventual-consistency.
19
+ *
20
+ * **Ref selection:** the latest write-back with a non-empty `commitSha` wins
21
+ * per workspace entry, across all executions in chronological order. FAILED
22
+ * write-back records carry no SHA and never regress the ref.
23
+ *
24
+ * **Entry matching** mirrors the runner's `WriteBackCoordinator.resolveEntry`:
25
+ * single-entry sessions provision with an empty `workspaceEntryName`, so when
26
+ * the session has exactly one git entry every write-back applies to it;
27
+ * multi-entry sessions match strictly by name.
28
+ *
29
+ * **Reference stability (DD-010):** the decorated array is memoized on the
30
+ * entries array and a value signature of the derived refs — streaming frames
31
+ * that don't change any write-back SHA return the identical array, so
32
+ * downstream listing/content effects never churn.
33
+ *
34
+ * @param executions - All executions for the session, in chronological order
35
+ * (same input contract as {@link useSessionWriteBacks}).
36
+ * @param entries - The session's workspace entries. Returned as-is (same
37
+ * reference) when no entry derives a ref.
38
+ *
39
+ * @example
40
+ * ```tsx
41
+ * const surfaceEntries = useWorkspaceReadRefs(flow.allExecutions, flow.workspace.entries);
42
+ * <WorkspaceSurface entries={surfaceEntries} ... />
43
+ * ```
44
+ *
45
+ * @see useSessionWriteBacks — the write-back list this projection derives from
46
+ * @see WorkspaceEntry.readRef — the field this hook populates
47
+ */
48
+ export function useWorkspaceReadRefs(
49
+ executions: readonly AgentExecution[],
50
+ entries: readonly WorkspaceEntry[],
51
+ ): readonly WorkspaceEntry[] {
52
+ // Cheap per-render pass; memoizing on `executions` would defeat the point —
53
+ // its identity changes every streaming frame while the SHAs rarely do. The
54
+ // signature collapses those frames into a stable memo key.
55
+ const refByName = collectLatestShas(executions);
56
+ const signature = [...refByName]
57
+ .map(([name, sha]) => `${name}\u0001${sha}`)
58
+ .join("\u0000");
59
+
60
+ return useMemo(() => {
61
+ if (refByName.size === 0) return entries;
62
+
63
+ const gitEntries = entries.filter((entry) => entry.type === "git");
64
+ let decorated = false;
65
+
66
+ const next = entries.map((entry) => {
67
+ const readRef = resolveReadRef(entry, gitEntries.length, refByName);
68
+ if (!readRef || readRef === entry.readRef) return entry;
69
+ decorated = true;
70
+ return { ...entry, readRef };
71
+ });
72
+
73
+ return decorated ? next : entries;
74
+ // `signature` is the value-equality key for `refByName` (rebuilt each
75
+ // render); the map itself must stay out of the deps.
76
+ }, [entries, signature]);
77
+ }
78
+
79
+ /** Latest non-empty write-back commit SHA per workspace entry name. */
80
+ function collectLatestShas(
81
+ executions: readonly AgentExecution[],
82
+ ): Map<string, string> {
83
+ const refByName = new Map<string, string>();
84
+ for (const execution of executions) {
85
+ for (const wb of execution.status?.workspaceWriteBacks ?? []) {
86
+ if (wb.commitSha) refByName.set(wb.workspaceEntryName, wb.commitSha);
87
+ }
88
+ }
89
+ return refByName;
90
+ }
91
+
92
+ /**
93
+ * The write-back SHA for one entry, honoring the runner's single-entry
94
+ * convention: with exactly one git entry, any write-back applies to it
95
+ * (single-entry sessions write back under an empty entry name).
96
+ */
97
+ function resolveReadRef(
98
+ entry: WorkspaceEntry,
99
+ gitEntryCount: number,
100
+ refByName: ReadonlyMap<string, string>,
101
+ ): string | undefined {
102
+ if (entry.type !== "git") return undefined;
103
+
104
+ const byName = refByName.get(entry.name);
105
+ if (byName) return byName;
106
+
107
+ if (gitEntryCount === 1) {
108
+ // Single-entry sessions write back under an empty entry name; a lone
109
+ // unambiguous name is equally safe to claim. Anything else (multiple
110
+ // names against one entry) is malformed status data — decorate nothing
111
+ // rather than guess.
112
+ const unnamed = refByName.get("");
113
+ if (unnamed) return unnamed;
114
+ if (refByName.size === 1) return refByName.values().next().value;
115
+ }
116
+
117
+ return undefined;
118
+ }