@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.
- package/execution/FileReviewContext.d.ts +15 -1
- package/execution/FileReviewContext.d.ts.map +1 -1
- package/execution/FileReviewContext.js +20 -1
- package/execution/FileReviewContext.js.map +1 -1
- package/execution/MessageThread.d.ts +65 -2
- package/execution/MessageThread.d.ts.map +1 -1
- package/execution/MessageThread.js +42 -23
- package/execution/MessageThread.js.map +1 -1
- package/execution/TodoCard.d.ts +8 -0
- package/execution/TodoCard.d.ts.map +1 -1
- package/execution/TodoCard.js +6 -4
- package/execution/TodoCard.js.map +1 -1
- package/execution/TodoList.d.ts +20 -1
- package/execution/TodoList.d.ts.map +1 -1
- package/execution/TodoList.js +9 -6
- package/execution/TodoList.js.map +1 -1
- package/execution/ToolCallDetail.d.ts +3 -2
- package/execution/ToolCallDetail.d.ts.map +1 -1
- package/execution/ToolCallDetail.js +49 -16
- package/execution/ToolCallDetail.js.map +1 -1
- package/execution/file-review-status.d.ts +15 -0
- package/execution/file-review-status.d.ts.map +1 -1
- package/execution/file-review-status.js +22 -0
- package/execution/file-review-status.js.map +1 -1
- package/execution/index.d.ts +5 -5
- package/execution/index.d.ts.map +1 -1
- package/execution/index.js +3 -3
- package/execution/index.js.map +1 -1
- package/github/useGitHubFileReader.d.ts +4 -2
- package/github/useGitHubFileReader.d.ts.map +1 -1
- package/github/useGitHubFileReader.js +16 -3
- package/github/useGitHubFileReader.js.map +1 -1
- package/github/useGitHubTreeLister.d.ts +2 -1
- package/github/useGitHubTreeLister.d.ts.map +1 -1
- package/github/useGitHubTreeLister.js +7 -3
- package/github/useGitHubTreeLister.js.map +1 -1
- package/index.d.ts +3 -3
- package/index.d.ts.map +1 -1
- package/index.js +3 -3
- package/index.js.map +1 -1
- package/internal/VirtualizedThread.d.ts +3 -2
- package/internal/VirtualizedThread.d.ts.map +1 -1
- package/internal/VirtualizedThread.js +3 -2
- package/internal/VirtualizedThread.js.map +1 -1
- package/package.json +4 -4
- package/session/SessionViewer.d.ts +11 -1
- package/session/SessionViewer.d.ts.map +1 -1
- package/session/SessionViewer.js +12 -5
- package/session/SessionViewer.js.map +1 -1
- package/session/index.d.ts +1 -0
- package/session/index.d.ts.map +1 -1
- package/session/index.js +1 -0
- package/session/index.js.map +1 -1
- package/session/useWorkspaceReadRefs.d.ts +45 -0
- package/session/useWorkspaceReadRefs.d.ts.map +1 -0
- package/session/useWorkspaceReadRefs.js +104 -0
- package/session/useWorkspaceReadRefs.js.map +1 -0
- package/src/execution/FileReviewContext.ts +27 -1
- package/src/execution/MessageThread.tsx +110 -22
- package/src/execution/TodoCard.tsx +20 -4
- package/src/execution/TodoList.tsx +26 -4
- package/src/execution/ToolCallDetail.tsx +69 -16
- package/src/execution/__tests__/ToolCallDetail.test.tsx +188 -2
- package/src/execution/__tests__/file-review-status.test.ts +47 -0
- package/src/execution/__tests__/message-thread-slots.test.tsx +463 -0
- package/src/execution/file-review-status.ts +24 -0
- package/src/execution/index.ts +13 -3
- package/src/github/__tests__/useGitHubFileReader.test.ts +35 -2
- package/src/github/__tests__/useGitHubTreeLister.test.ts +14 -0
- package/src/github/useGitHubFileReader.ts +19 -6
- package/src/github/useGitHubTreeLister.ts +7 -3
- package/src/index.ts +4 -0
- package/src/internal/VirtualizedThread.tsx +5 -1
- package/src/session/SessionViewer.tsx +27 -2
- package/src/session/__tests__/useWorkspaceReadRefs.test.ts +245 -0
- package/src/session/index.ts +2 -0
- package/src/session/useWorkspaceReadRefs.ts +118 -0
- package/src/workspace/FileViewer.tsx +144 -38
- package/src/workspace/WorkspaceFileReader.ts +21 -0
- package/src/workspace/__tests__/FileViewer.test.tsx +138 -3
- package/src/workspace/__tests__/useWorkspaceFileContent.test.tsx +20 -0
- package/src/workspace/__tests__/workspaceListingCache.test.ts +114 -0
- package/src/workspace/index.ts +4 -1
- package/src/workspace/useWorkspaceEntries.ts +11 -0
- package/src/workspace/useWorkspaceFileContent.ts +4 -1
- package/src/workspace/useWorkspaceFileSearch.ts +8 -3
- package/src/workspace/useWorkspaceFiles.ts +3 -2
- package/src/workspace/workspaceListingCache.ts +21 -7
- package/styles.css +1 -1
- package/workspace/FileViewer.d.ts +1 -1
- package/workspace/FileViewer.d.ts.map +1 -1
- package/workspace/FileViewer.js +55 -5
- package/workspace/FileViewer.js.map +1 -1
- package/workspace/WorkspaceFileReader.d.ts +17 -0
- package/workspace/WorkspaceFileReader.d.ts.map +1 -1
- package/workspace/WorkspaceFileReader.js +18 -0
- package/workspace/WorkspaceFileReader.js.map +1 -1
- package/workspace/index.d.ts +1 -1
- package/workspace/index.d.ts.map +1 -1
- package/workspace/index.js +1 -1
- package/workspace/index.js.map +1 -1
- package/workspace/useWorkspaceEntries.d.ts +11 -0
- package/workspace/useWorkspaceEntries.d.ts.map +1 -1
- package/workspace/useWorkspaceEntries.js.map +1 -1
- package/workspace/useWorkspaceFileContent.d.ts.map +1 -1
- package/workspace/useWorkspaceFileContent.js +4 -1
- package/workspace/useWorkspaceFileContent.js.map +1 -1
- package/workspace/useWorkspaceFileSearch.d.ts.map +1 -1
- package/workspace/useWorkspaceFileSearch.js +8 -3
- package/workspace/useWorkspaceFileSearch.js.map +1 -1
- package/workspace/useWorkspaceFiles.d.ts +2 -1
- package/workspace/useWorkspaceFiles.d.ts.map +1 -1
- package/workspace/useWorkspaceFiles.js +3 -2
- package/workspace/useWorkspaceFiles.js.map +1 -1
- package/workspace/workspaceListingCache.d.ts +1 -1
- package/workspace/workspaceListingCache.d.ts.map +1 -1
- package/workspace/workspaceListingCache.js +20 -7
- 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/{
|
|
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
|
-
|
|
68
|
-
|
|
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={
|
|
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
|
+
});
|
package/src/session/index.ts
CHANGED
|
@@ -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
|
+
}
|