@stigmer/react 0.0.53 → 0.0.54

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 (113) hide show
  1. package/execution/ArtifactCard.d.ts +11 -1
  2. package/execution/ArtifactCard.d.ts.map +1 -1
  3. package/execution/ArtifactCard.js +22 -3
  4. package/execution/ArtifactCard.js.map +1 -1
  5. package/execution/ArtifactPreviewModal.d.ts.map +1 -1
  6. package/execution/ArtifactPreviewModal.js +1 -1
  7. package/execution/ArtifactPreviewModal.js.map +1 -1
  8. package/execution/ArtifactsWidget.d.ts +26 -19
  9. package/execution/ArtifactsWidget.d.ts.map +1 -1
  10. package/execution/ArtifactsWidget.js +24 -26
  11. package/execution/ArtifactsWidget.js.map +1 -1
  12. package/execution/MessageThread.d.ts +10 -1
  13. package/execution/MessageThread.d.ts.map +1 -1
  14. package/execution/MessageThread.js +19 -17
  15. package/execution/MessageThread.js.map +1 -1
  16. package/execution/SandboxContext.d.ts +32 -0
  17. package/execution/SandboxContext.d.ts.map +1 -0
  18. package/execution/SandboxContext.js +26 -0
  19. package/execution/SandboxContext.js.map +1 -0
  20. package/execution/ToolArgsView.d.ts.map +1 -1
  21. package/execution/ToolArgsView.js +3 -1
  22. package/execution/ToolArgsView.js.map +1 -1
  23. package/execution/ToolCallDetail.d.ts.map +1 -1
  24. package/execution/ToolCallDetail.js +3 -1
  25. package/execution/ToolCallDetail.js.map +1 -1
  26. package/execution/ToolCallItem.d.ts.map +1 -1
  27. package/execution/ToolCallItem.js +7 -1
  28. package/execution/ToolCallItem.js.map +1 -1
  29. package/execution/WriteBackCard.d.ts +34 -0
  30. package/execution/WriteBackCard.d.ts.map +1 -0
  31. package/execution/WriteBackCard.js +75 -0
  32. package/execution/WriteBackCard.js.map +1 -0
  33. package/execution/WriteBacksWidget.d.ts +49 -0
  34. package/execution/WriteBacksWidget.d.ts.map +1 -0
  35. package/execution/WriteBacksWidget.js +44 -0
  36. package/execution/WriteBacksWidget.js.map +1 -0
  37. package/execution/__tests__/file-path-resolver.test.d.ts +2 -0
  38. package/execution/__tests__/file-path-resolver.test.d.ts.map +1 -0
  39. package/execution/__tests__/file-path-resolver.test.js +180 -0
  40. package/execution/__tests__/file-path-resolver.test.js.map +1 -0
  41. package/execution/file-path-resolver.d.ts +3 -3
  42. package/execution/file-path-resolver.d.ts.map +1 -1
  43. package/execution/file-path-resolver.js +23 -12
  44. package/execution/file-path-resolver.js.map +1 -1
  45. package/execution/index.d.ts +9 -0
  46. package/execution/index.d.ts.map +1 -1
  47. package/execution/index.js +5 -0
  48. package/execution/index.js.map +1 -1
  49. package/execution/sandbox-path-normalizer.d.ts +46 -0
  50. package/execution/sandbox-path-normalizer.d.ts.map +1 -0
  51. package/execution/sandbox-path-normalizer.js +73 -0
  52. package/execution/sandbox-path-normalizer.js.map +1 -0
  53. package/execution/useArtifactContent.d.ts +5 -1
  54. package/execution/useArtifactContent.d.ts.map +1 -1
  55. package/execution/useArtifactContent.js +6 -2
  56. package/execution/useArtifactContent.js.map +1 -1
  57. package/execution/useWorkspaceWriteBacks.d.ts +40 -0
  58. package/execution/useWorkspaceWriteBacks.d.ts.map +1 -0
  59. package/execution/useWorkspaceWriteBacks.js +41 -0
  60. package/execution/useWorkspaceWriteBacks.js.map +1 -0
  61. package/github/GitHubRepoPicker.d.ts +5 -2
  62. package/github/GitHubRepoPicker.d.ts.map +1 -1
  63. package/github/GitHubRepoPicker.js +133 -36
  64. package/github/GitHubRepoPicker.js.map +1 -1
  65. package/github/index.d.ts +1 -0
  66. package/github/index.d.ts.map +1 -1
  67. package/github/index.js +1 -0
  68. package/github/index.js.map +1 -1
  69. package/github/useGitHubSearch.d.ts +20 -0
  70. package/github/useGitHubSearch.d.ts.map +1 -0
  71. package/github/useGitHubSearch.js +127 -0
  72. package/github/useGitHubSearch.js.map +1 -0
  73. package/index.d.ts +6 -6
  74. package/index.d.ts.map +1 -1
  75. package/index.js +3 -3
  76. package/index.js.map +1 -1
  77. package/package.json +4 -4
  78. package/session/index.d.ts +4 -0
  79. package/session/index.d.ts.map +1 -1
  80. package/session/index.js +2 -0
  81. package/session/index.js.map +1 -1
  82. package/session/useSessionArtifacts.d.ts +73 -0
  83. package/session/useSessionArtifacts.d.ts.map +1 -0
  84. package/session/useSessionArtifacts.js +95 -0
  85. package/session/useSessionArtifacts.js.map +1 -0
  86. package/session/useSessionWriteBacks.d.ts +56 -0
  87. package/session/useSessionWriteBacks.d.ts.map +1 -0
  88. package/session/useSessionWriteBacks.js +56 -0
  89. package/session/useSessionWriteBacks.js.map +1 -0
  90. package/src/execution/ArtifactCard.tsx +40 -0
  91. package/src/execution/ArtifactPreviewModal.tsx +2 -0
  92. package/src/execution/ArtifactsWidget.tsx +51 -43
  93. package/src/execution/MessageThread.tsx +18 -0
  94. package/src/execution/SandboxContext.ts +47 -0
  95. package/src/execution/ToolArgsView.tsx +3 -1
  96. package/src/execution/ToolCallDetail.tsx +3 -1
  97. package/src/execution/ToolCallItem.tsx +7 -1
  98. package/src/execution/WriteBackCard.tsx +210 -0
  99. package/src/execution/WriteBacksWidget.tsx +82 -0
  100. package/src/execution/__tests__/file-path-resolver.test.ts +295 -0
  101. package/src/execution/file-path-resolver.ts +24 -12
  102. package/src/execution/index.ts +13 -0
  103. package/src/execution/sandbox-path-normalizer.ts +80 -0
  104. package/src/execution/useArtifactContent.ts +6 -1
  105. package/src/execution/useWorkspaceWriteBacks.ts +56 -0
  106. package/src/github/GitHubRepoPicker.tsx +413 -108
  107. package/src/github/index.ts +5 -0
  108. package/src/github/useGitHubSearch.ts +162 -0
  109. package/src/index.ts +14 -0
  110. package/src/session/index.ts +12 -0
  111. package/src/session/useSessionArtifacts.ts +143 -0
  112. package/src/session/useSessionWriteBacks.ts +94 -0
  113. package/styles.css +1 -1
@@ -0,0 +1,295 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { create } from "@bufbuild/protobuf";
3
+ import {
4
+ WorkspaceEntrySchema,
5
+ WorkspaceSourceSchema,
6
+ GitRepoSourceSchema,
7
+ LocalPathSourceSchema,
8
+ } from "@stigmer/protos/ai/stigmer/agentic/session/v1/workspace_pb";
9
+ import type { WorkspaceEntry } from "@stigmer/protos/ai/stigmer/agentic/session/v1/workspace_pb";
10
+ import {
11
+ classifyPath,
12
+ resolveGitBrowseUrl,
13
+ resolvePathAction,
14
+ } from "../file-path-resolver";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Factory helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ function gitEntry(name: string, url: string, branch = "main"): WorkspaceEntry {
21
+ return create(WorkspaceEntrySchema, {
22
+ name,
23
+ source: create(WorkspaceSourceSchema, {
24
+ source: {
25
+ case: "gitRepo",
26
+ value: create(GitRepoSourceSchema, { url, branch, commit: "" }),
27
+ },
28
+ }),
29
+ });
30
+ }
31
+
32
+ function localEntry(name: string, path: string): WorkspaceEntry {
33
+ return create(WorkspaceEntrySchema, {
34
+ name,
35
+ source: create(WorkspaceSourceSchema, {
36
+ source: {
37
+ case: "localPath",
38
+ value: create(LocalPathSourceSchema, { path }),
39
+ },
40
+ }),
41
+ });
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // classifyPath
46
+ // ---------------------------------------------------------------------------
47
+
48
+ describe("classifyPath", () => {
49
+ it("classifies platform paths", () => {
50
+ expect(classifyPath(".stigmer/skills/foo.md")).toEqual({
51
+ kind: "platform",
52
+ subpath: "skills/foo.md",
53
+ });
54
+ });
55
+
56
+ it("classifies bare .stigmer directory", () => {
57
+ expect(classifyPath(".stigmer")).toEqual({
58
+ kind: "platform",
59
+ subpath: "",
60
+ });
61
+ });
62
+
63
+ it("strips leading slashes from platform paths", () => {
64
+ expect(classifyPath("/.stigmer/inputs/bar")).toEqual({
65
+ kind: "platform",
66
+ subpath: "inputs/bar",
67
+ });
68
+ });
69
+
70
+ it("classifies workspace paths", () => {
71
+ expect(classifyPath("src/main.go")).toEqual({
72
+ kind: "workspace",
73
+ remainder: "src/main.go",
74
+ });
75
+ });
76
+
77
+ it("strips leading slashes from workspace paths", () => {
78
+ expect(classifyPath("/src/main.go")).toEqual({
79
+ kind: "workspace",
80
+ remainder: "src/main.go",
81
+ });
82
+ });
83
+ });
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // resolveGitBrowseUrl
87
+ // ---------------------------------------------------------------------------
88
+
89
+ describe("resolveGitBrowseUrl", () => {
90
+ it("constructs a correct GitHub blob URL", () => {
91
+ expect(
92
+ resolveGitBrowseUrl(
93
+ "https://github.com/acme/app.git",
94
+ "main",
95
+ "",
96
+ "src/index.ts",
97
+ ),
98
+ ).toBe("https://github.com/acme/app/blob/main/src/index.ts");
99
+ });
100
+
101
+ it("prefers commit over branch", () => {
102
+ expect(
103
+ resolveGitBrowseUrl(
104
+ "https://github.com/acme/app.git",
105
+ "main",
106
+ "abc123",
107
+ "README.md",
108
+ ),
109
+ ).toBe("https://github.com/acme/app/blob/abc123/README.md");
110
+ });
111
+
112
+ it("falls back to HEAD when both branch and commit are empty", () => {
113
+ expect(
114
+ resolveGitBrowseUrl(
115
+ "https://github.com/acme/app.git",
116
+ "",
117
+ "",
118
+ "README.md",
119
+ ),
120
+ ).toBe("https://github.com/acme/app/blob/HEAD/README.md");
121
+ });
122
+
123
+ it("returns null for non-GitHub hosts", () => {
124
+ expect(
125
+ resolveGitBrowseUrl(
126
+ "https://gitlab.com/acme/app.git",
127
+ "main",
128
+ "",
129
+ "src/main.go",
130
+ ),
131
+ ).toBeNull();
132
+ });
133
+
134
+ it("returns null for invalid URLs", () => {
135
+ expect(
136
+ resolveGitBrowseUrl("not-a-url", "main", "", "file.txt"),
137
+ ).toBeNull();
138
+ });
139
+
140
+ it("strips duplicate org/repo prefix from relPath", () => {
141
+ expect(
142
+ resolveGitBrowseUrl(
143
+ "https://github.com/plantonhq/agent-fleet.git",
144
+ "main",
145
+ "",
146
+ "plantonhq/agent-fleet/mcp-servers/mcp-server-planton.yaml",
147
+ ),
148
+ ).toBe(
149
+ "https://github.com/plantonhq/agent-fleet/blob/main/mcp-servers/mcp-server-planton.yaml",
150
+ );
151
+ });
152
+
153
+ it("does not strip when relPath shares a partial prefix", () => {
154
+ expect(
155
+ resolveGitBrowseUrl(
156
+ "https://github.com/acme/app.git",
157
+ "main",
158
+ "",
159
+ "acme/other-thing/file.txt",
160
+ ),
161
+ ).toBe("https://github.com/acme/app/blob/main/acme/other-thing/file.txt");
162
+ });
163
+
164
+ it("handles clone URLs without .git suffix", () => {
165
+ expect(
166
+ resolveGitBrowseUrl(
167
+ "https://github.com/acme/app",
168
+ "main",
169
+ "",
170
+ "src/lib.rs",
171
+ ),
172
+ ).toBe("https://github.com/acme/app/blob/main/src/lib.rs");
173
+ });
174
+ });
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // resolvePathAction — integration (exercises matchWorkspaceEntry internally)
178
+ // ---------------------------------------------------------------------------
179
+
180
+ describe("resolvePathAction", () => {
181
+ it("returns copy for empty path", () => {
182
+ const result = resolvePathAction("", []);
183
+ expect(result.action).toBe("copy");
184
+ });
185
+
186
+ it("returns copy for platform paths", () => {
187
+ const entries = [gitEntry("app", "https://github.com/acme/app.git")];
188
+ const result = resolvePathAction(".stigmer/skills/foo.md", entries);
189
+ expect(result.action).toBe("copy");
190
+ });
191
+
192
+ it("returns copy when no workspace entries", () => {
193
+ const result = resolvePathAction("src/main.go", []);
194
+ expect(result.action).toBe("copy");
195
+ });
196
+
197
+ describe("single git workspace entry", () => {
198
+ const entries = [
199
+ gitEntry("agent-fleet", "https://github.com/plantonhq/agent-fleet.git"),
200
+ ];
201
+
202
+ it("produces a GitHub link for a relative path", () => {
203
+ const result = resolvePathAction(
204
+ "mcp-servers/mcp-server-planton.yaml",
205
+ entries,
206
+ );
207
+ expect(result).toEqual({
208
+ action: "link",
209
+ url: "https://github.com/plantonhq/agent-fleet/blob/main/mcp-servers/mcp-server-planton.yaml",
210
+ tooltip: "Open on GitHub",
211
+ });
212
+ });
213
+ });
214
+
215
+ describe("multiple git workspace entries — first-segment match", () => {
216
+ const entries = [
217
+ gitEntry(
218
+ "mcp-server-planton",
219
+ "https://github.com/plantonhq/mcp-server-planton.git",
220
+ ),
221
+ gitEntry(
222
+ "agent-fleet",
223
+ "https://github.com/plantonhq/agent-fleet.git",
224
+ ),
225
+ ];
226
+
227
+ it("matches entry by first path segment and strips it", () => {
228
+ const result = resolvePathAction(
229
+ "agent-fleet/mcp-servers/mcp-server-planton.yaml",
230
+ entries,
231
+ );
232
+ expect(result).toEqual({
233
+ action: "link",
234
+ url: "https://github.com/plantonhq/agent-fleet/blob/main/mcp-servers/mcp-server-planton.yaml",
235
+ tooltip: "Open on GitHub",
236
+ });
237
+ });
238
+ });
239
+
240
+ describe("multiple git workspace entries — deep segment match (bug fix)", () => {
241
+ const entries = [
242
+ gitEntry(
243
+ "mcp-server-planton",
244
+ "https://github.com/plantonhq/mcp-server-planton.git",
245
+ ),
246
+ gitEntry(
247
+ "agent-fleet",
248
+ "https://github.com/plantonhq/agent-fleet.git",
249
+ ),
250
+ ];
251
+
252
+ it("matches entry by deeper segment when org prefix is present", () => {
253
+ const result = resolvePathAction(
254
+ "plantonhq/agent-fleet/mcp-servers/mcp-server-planton.yaml",
255
+ entries,
256
+ );
257
+ expect(result).toEqual({
258
+ action: "link",
259
+ url: "https://github.com/plantonhq/agent-fleet/blob/main/mcp-servers/mcp-server-planton.yaml",
260
+ tooltip: "Open on GitHub",
261
+ });
262
+ });
263
+ });
264
+
265
+ describe("fallback to first entry when no segment matches", () => {
266
+ const entries = [
267
+ gitEntry("my-app", "https://github.com/acme/my-app.git"),
268
+ gitEntry("my-lib", "https://github.com/acme/my-lib.git"),
269
+ ];
270
+
271
+ it("falls back to entries[0] with full path as relPath", () => {
272
+ const result = resolvePathAction("unknown/path/file.txt", entries);
273
+ expect(result).toEqual({
274
+ action: "link",
275
+ url: "https://github.com/acme/my-app/blob/main/unknown/path/file.txt",
276
+ tooltip: "Open on GitHub",
277
+ });
278
+ });
279
+ });
280
+
281
+ describe("local path workspace entry", () => {
282
+ const entries = [
283
+ localEntry("my-app", "/Users/dev/projects/my-app"),
284
+ ];
285
+
286
+ it("joins local path for copy action", () => {
287
+ const result = resolvePathAction("src/main.go", entries);
288
+ expect(result).toEqual({
289
+ action: "copy",
290
+ value: "/Users/dev/projects/my-app/src/main.go",
291
+ tooltip: "Copy path",
292
+ });
293
+ });
294
+ });
295
+ });
@@ -62,7 +62,15 @@ export function resolveGitBrowseUrl(
62
62
  if (!repoPath) return null;
63
63
 
64
64
  const ref = commit || branch || "HEAD";
65
- const cleanRelPath = relPath.replace(/^\/+/, "");
65
+ let cleanRelPath = relPath.replace(/^\/+/, "");
66
+
67
+ // Guard against relPath that accidentally duplicates the org/repo
68
+ // already encoded in gitUrl (e.g. relPath = "acme/app/src/main.go"
69
+ // when repoPath is "acme/app"). Strip the redundant prefix so the
70
+ // final URL doesn't contain the repo path twice.
71
+ if (cleanRelPath.startsWith(repoPath + "/")) {
72
+ cleanRelPath = cleanRelPath.slice(repoPath.length + 1);
73
+ }
66
74
 
67
75
  return `https://github.com/${repoPath}/blob/${ref}/${cleanRelPath}`;
68
76
  }
@@ -83,9 +91,9 @@ export type ResolvedPathAction =
83
91
  * 1. **Platform paths** (`.stigmer/` prefix) → copy raw path.
84
92
  * 2. **Workspace paths** → match against workspace entries:
85
93
  * - Single entry: treat path as relative to that entry.
86
- * - Multiple entries: match first path segment against entry names.
87
- * Unmatched segments fall back to the first entry (optimistic —
88
- * single-workspace sessions are the common case).
94
+ * - Multiple entries: scan path segments for an entry name match
95
+ * (handles org-prefixed paths like `plantonhq/agent-fleet/...`).
96
+ * Unmatched paths fall back to the first entry.
89
97
  * 3. **Git source** → construct GitHub blob URL.
90
98
  * 4. **Local source** → join with absolute local path for copy.
91
99
  * 5. **Fallback** → copy the raw path.
@@ -139,8 +147,11 @@ export function resolvePathAction(
139
147
  /**
140
148
  * Matches a workspace-relative path against entries. With a single
141
149
  * entry the path is used as-is. With multiple entries the first path
142
- * segment is tested against entry names; unmatched paths fall back to
143
- * the first entry (same strategy as the CLI).
150
+ * segment is tested against entry names. When the first segment
151
+ * doesn't match, deeper segments are scanned this handles tool-call
152
+ * paths that embed an org prefix (e.g. `plantonhq/agent-fleet/...`
153
+ * where the entry name is `agent-fleet`). Unmatched paths fall back
154
+ * to the first entry.
144
155
  */
145
156
  function matchWorkspaceEntry(
146
157
  relPath: string,
@@ -150,13 +161,14 @@ function matchWorkspaceEntry(
150
161
  return { entry: entries[0], relPath };
151
162
  }
152
163
 
153
- const slashIdx = relPath.indexOf("/");
154
- const firstSegment = slashIdx >= 0 ? relPath.slice(0, slashIdx) : relPath;
164
+ const segments = relPath.split("/");
155
165
 
156
- for (const entry of entries) {
157
- if (entry.name === firstSegment) {
158
- const rest = slashIdx >= 0 ? relPath.slice(slashIdx + 1) : "";
159
- return { entry, relPath: rest };
166
+ for (let i = 0; i < segments.length; i++) {
167
+ for (const entry of entries) {
168
+ if (entry.name === segments[i]) {
169
+ const rest = segments.slice(i + 1).join("/");
170
+ return { entry, relPath: rest };
171
+ }
160
172
  }
161
173
  }
162
174
 
@@ -19,6 +19,12 @@ export type { UseExecutionArtifactsReturn } from "./useExecutionArtifacts";
19
19
  export { useArtifactContent } from "./useArtifactContent";
20
20
  export type { UseArtifactContentReturn } from "./useArtifactContent";
21
21
 
22
+ export { useWorkspaceWriteBacks } from "./useWorkspaceWriteBacks";
23
+ export type { UseWorkspaceWriteBacksReturn } from "./useWorkspaceWriteBacks";
24
+
25
+ export { WriteBackCard } from "./WriteBackCard";
26
+ export type { WriteBackCardProps } from "./WriteBackCard";
27
+
22
28
  export {
23
29
  isTextArtifact,
24
30
  isArtifactExpired,
@@ -104,6 +110,9 @@ export type { ArtifactPreviewModalProps } from "./ArtifactPreviewModal";
104
110
  export { ArtifactsWidget } from "./ArtifactsWidget";
105
111
  export type { ArtifactsWidgetProps } from "./ArtifactsWidget";
106
112
 
113
+ export { WriteBacksWidget } from "./WriteBacksWidget";
114
+ export type { WriteBacksWidgetProps } from "./WriteBacksWidget";
115
+
107
116
  export {
108
117
  resolveToolCategory,
109
118
  extractPrimaryArg,
@@ -118,6 +127,10 @@ export type { FilePathLinkProps } from "./FilePathLink";
118
127
  export { FilePathContext } from "./FilePathContext";
119
128
  export type { FilePathContextValue } from "./FilePathContext";
120
129
 
130
+ export { normalizeSandboxPaths } from "./sandbox-path-normalizer";
131
+ export { SandboxContext, useSandboxNormalize } from "./SandboxContext";
132
+ export type { SandboxContextValue } from "./SandboxContext";
133
+
121
134
  export { classifyPath, resolveGitBrowseUrl, resolvePathAction } from "./file-path-resolver";
122
135
  export type { PathClassification, ResolvedPathAction } from "./file-path-resolver";
123
136
 
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Display-time normalization for sandbox workspace paths.
3
+ *
4
+ * Mirrors the Python `humanize_sandbox_paths` in
5
+ * `graphton.core.backends.platform_mount` — same semantics, same
6
+ * replacement order. The SDK version acts as a safety net for
7
+ * historical data (persisted before backend humanization was added)
8
+ * and streaming edge cases.
9
+ *
10
+ * @module
11
+ */
12
+
13
+ /**
14
+ * Replace absolute sandbox workspace paths with workspace-relative
15
+ * display paths.
16
+ *
17
+ * Performs three ordered replacements:
18
+ *
19
+ * 1. `workspaceRoot + "/"` → empty string (makes paths workspace-relative)
20
+ * 2. `workspaceRoot` (exact) → `"."` (the workspace root itself)
21
+ * 3. Parent of `workspaceRoot` (sandbox home) → `"~"` (Unix convention)
22
+ *
23
+ * Returns `text` unchanged when `workspaceRoot` is empty.
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * normalizeSandboxPaths(
28
+ * "ls /home/daytona/workspace/plantonhq/",
29
+ * "/home/daytona/workspace",
30
+ * );
31
+ * // => "ls plantonhq/"
32
+ *
33
+ * normalizeSandboxPaths(
34
+ * "cd /home/daytona/workspace && ls",
35
+ * "/home/daytona/workspace",
36
+ * );
37
+ * // => "cd . && ls"
38
+ *
39
+ * normalizeSandboxPaths(
40
+ * "cat /home/daytona/.bashrc",
41
+ * "/home/daytona/workspace",
42
+ * );
43
+ * // => "cat ~/.bashrc"
44
+ * ```
45
+ */
46
+ export function normalizeSandboxPaths(
47
+ text: string,
48
+ workspaceRoot: string,
49
+ ): string {
50
+ if (!text || !workspaceRoot) return text;
51
+
52
+ const wsRoot = workspaceRoot.replace(/\/+$/, "");
53
+
54
+ // 1) Strip workspace root prefix (with trailing slash) → workspace-relative
55
+ text = replaceAll(text, wsRoot + "/", "");
56
+
57
+ // 2) Replace bare workspace root → "."
58
+ text = replaceAll(text, wsRoot, ".");
59
+
60
+ // 3) Replace sandbox home prefix → "~"
61
+ const lastSlash = wsRoot.lastIndexOf("/");
62
+ if (lastSlash > 0) {
63
+ const sandboxHome = wsRoot.slice(0, lastSlash);
64
+ text = replaceAll(text, sandboxHome + "/", "~/");
65
+ text = replaceAll(text, sandboxHome, "~");
66
+ }
67
+
68
+ return text;
69
+ }
70
+
71
+ function replaceAll(text: string, search: string, replacement: string): string {
72
+ if (!search) return text;
73
+ let result = text;
74
+ let idx = result.indexOf(search);
75
+ while (idx !== -1) {
76
+ result = result.slice(0, idx) + replacement + result.slice(idx + search.length);
77
+ idx = result.indexOf(search, idx + replacement.length);
78
+ }
79
+ return result;
80
+ }
@@ -80,6 +80,10 @@ export interface UseArtifactContentReturn {
80
80
  * @param storageKey - Storage key from `ExecutionArtifact.storageKey`, or `null` to skip.
81
81
  * @param entryPath - For directory artifacts: relative path of a file within
82
82
  * the archive to extract. `null` returns the full artifact (existing behavior).
83
+ * @param contentHash - SHA-256 hex digest from `ExecutionArtifact.contentHash`.
84
+ * When the same file is overwritten during execution, the `storageKey` stays
85
+ * stable but `contentHash` changes, triggering a re-fetch so the UI never
86
+ * shows stale content. Pass `undefined` or omit for backwards compatibility.
83
87
  *
84
88
  * @see useExecutionArtifacts — extracts artifact metadata from an execution
85
89
  * @see isTextArtifact — heuristic for whether content is fetchable as text
@@ -88,6 +92,7 @@ export function useArtifactContent(
88
92
  executionId: string | null,
89
93
  storageKey: string | null,
90
94
  entryPath?: string | null,
95
+ contentHash?: string,
91
96
  ): UseArtifactContentReturn {
92
97
  const stigmer = useStigmer();
93
98
 
@@ -150,7 +155,7 @@ export function useArtifactContent(
150
155
  return () => {
151
156
  cancelled.current = true;
152
157
  };
153
- }, [executionId, storageKey, entryPath, stigmer, fetchKey]);
158
+ }, [executionId, storageKey, entryPath, contentHash, stigmer, fetchKey]);
154
159
 
155
160
  return { content, contentType, isTruncated, isLoading, error, refetch };
156
161
  }
@@ -0,0 +1,56 @@
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 { WorkspaceWriteBack } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/writeback_pb";
6
+
7
+ export interface UseWorkspaceWriteBacksReturn {
8
+ /** Write-back outcomes for git-backed workspace entries, ordered by workspace entry name. */
9
+ readonly writeBacks: readonly WorkspaceWriteBack[];
10
+ /** `true` when the execution has at least one write-back entry. */
11
+ readonly hasWriteBacks: boolean;
12
+ /** Total number of write-back entries. */
13
+ readonly writeBackCount: number;
14
+ }
15
+
16
+ /**
17
+ * Pure derivation hook that extracts workspace write-back data from an
18
+ * {@link AgentExecution} snapshot.
19
+ *
20
+ * Follows the same `useMemo`-based derivation pattern as
21
+ * {@link useExecutionArtifacts}: no side effects, no data fetching.
22
+ * The execution object (typically from {@link useExecutionStream}) is
23
+ * the single input.
24
+ *
25
+ * Returns an empty array when the execution is `null` or has no
26
+ * write-backs, eliminating null-checking at every consumer call site.
27
+ *
28
+ * Each `WorkspaceWriteBack` entry corresponds to a git-backed workspace
29
+ * entry where the platform detected file changes and ran the automatic
30
+ * branch/commit/push/PR workflow.
31
+ *
32
+ * @example
33
+ * ```tsx
34
+ * const { execution } = useExecutionStream(executionId);
35
+ * const { writeBacks, hasWriteBacks } = useWorkspaceWriteBacks(execution);
36
+ *
37
+ * if (hasWriteBacks) {
38
+ * writeBacks.forEach((wb) => console.log(wb.workspaceEntryName, wb.pullRequestUrl));
39
+ * }
40
+ * ```
41
+ *
42
+ * @see useExecutionArtifacts — similar derivation hook for artifacts
43
+ */
44
+ export function useWorkspaceWriteBacks(
45
+ execution: AgentExecution | null,
46
+ ): UseWorkspaceWriteBacksReturn {
47
+ return useMemo(() => {
48
+ const writeBacks = execution?.status?.workspaceWriteBacks ?? [];
49
+
50
+ return {
51
+ writeBacks,
52
+ hasWriteBacks: writeBacks.length > 0,
53
+ writeBackCount: writeBacks.length,
54
+ };
55
+ }, [execution]);
56
+ }