@stigmer/react 3.15.3-dev.20260914121148 → 3.15.3-dev.20260916211208

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 (36) hide show
  1. package/execution/ApprovalCard.d.ts +18 -5
  2. package/execution/ApprovalCard.d.ts.map +1 -1
  3. package/execution/ApprovalCard.js +24 -25
  4. package/execution/ApprovalCard.js.map +1 -1
  5. package/execution/ToolArgsView.js +1 -17
  6. package/execution/ToolArgsView.js.map +1 -1
  7. package/execution/ToolCallItem.d.ts.map +1 -1
  8. package/execution/ToolCallItem.js +4 -1
  9. package/execution/ToolCallItem.js.map +1 -1
  10. package/execution/tool-categories.d.ts +29 -6
  11. package/execution/tool-categories.d.ts.map +1 -1
  12. package/execution/tool-categories.js +61 -15
  13. package/execution/tool-categories.js.map +1 -1
  14. package/organization/OrgSwitcher.d.ts +3 -1
  15. package/organization/OrgSwitcher.d.ts.map +1 -1
  16. package/organization/OrgSwitcher.js +3 -1
  17. package/organization/OrgSwitcher.js.map +1 -1
  18. package/package.json +4 -4
  19. package/sharing/useCanCreateAgentShare.d.ts +5 -4
  20. package/sharing/useCanCreateAgentShare.d.ts.map +1 -1
  21. package/sharing/useCanCreateAgentShare.js +5 -4
  22. package/sharing/useCanCreateAgentShare.js.map +1 -1
  23. package/src/execution/ApprovalCard.tsx +38 -23
  24. package/src/execution/ToolArgsView.tsx +1 -20
  25. package/src/execution/ToolCallItem.tsx +4 -0
  26. package/src/execution/__tests__/ApprovalCard.test.tsx +110 -1
  27. package/src/execution/__tests__/ToolCallItem.test.tsx +38 -0
  28. package/src/execution/__tests__/write-content.test.ts +82 -0
  29. package/src/execution/tool-categories.ts +77 -17
  30. package/src/organization/OrgSwitcher.tsx +3 -1
  31. package/src/sharing/useCanCreateAgentShare.ts +5 -4
  32. package/src/user/UserMenu.tsx +3 -1
  33. package/user/UserMenu.d.ts +3 -1
  34. package/user/UserMenu.d.ts.map +1 -1
  35. package/user/UserMenu.js +3 -1
  36. package/user/UserMenu.js.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stigmer/react",
3
- "version": "3.15.3-dev.20260914121148",
3
+ "version": "3.15.3-dev.20260916211208",
4
4
  "description": "React provider and client hook for the Stigmer platform SDK",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -37,7 +37,7 @@
37
37
  "dependencies": {
38
38
  "@bufbuild/protovalidate": "^1.1.1",
39
39
  "@dagrejs/dagre": "^1.1.4",
40
- "@stigmer/theme": "3.15.3-dev.20260914121148",
40
+ "@stigmer/theme": "3.15.3-dev.20260916211208",
41
41
  "diff": "^8.0.3",
42
42
  "fflate": "^0.8.2",
43
43
  "hast-util-to-jsx-runtime": "^2.3.6",
@@ -60,8 +60,8 @@
60
60
  "@codemirror/state": "^6.0.0",
61
61
  "@codemirror/view": "^6.0.0",
62
62
  "@lezer/highlight": "^1.0.0",
63
- "@stigmer/protos": "3.15.3-dev.20260914121148",
64
- "@stigmer/sdk": "3.15.3-dev.20260914121148",
63
+ "@stigmer/protos": "3.15.3-dev.20260916211208",
64
+ "@stigmer/sdk": "3.15.3-dev.20260916211208",
65
65
  "@tanstack/react-table": "^8.20.0",
66
66
  "@xyflow/react": "^12.0.0",
67
67
  "elkjs": "^0.9.0",
@@ -28,10 +28,11 @@ export interface UseCanCreateAgentShareReturn {
28
28
  * resource id — an Organization's id equals its slug
29
29
  * (ApiResourceMetadata.id).
30
30
  *
31
- * On the open-source edition both checks reach the server's permissive
32
- * single-team authorizer and answer allowed the same posture the
33
- * backend enforces there (decision 011 D4), so the affordance matches
34
- * what a create would do.
31
+ * On the open-source edition the answer follows the server's posture. A
32
+ * server without sign-in has the permissive single-team authorizer and
33
+ * answers allowed (decision 011 D4). A server with sign-in enforces the
34
+ * same organization roles as the cloud, so `can_create_agent_share` is
35
+ * the admin's and a member sees no share affordance.
35
36
  *
36
37
  * Pass `null` for `agent` while it loads — `allowed` stays `false` so
37
38
  * no affordance flashes before the gate can be evaluated.
@@ -1 +1 @@
1
- {"version":3,"file":"useCanCreateAgentShare.d.ts","sourceRoot":"","sources":["../../src/sharing/useCanCreateAgentShare.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,oDAAoD,CAAC;AAIhF,sDAAsD;AACtD,MAAM,WAAW,4BAA4B;IAC3C,yEAAyE;IACzE,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,gFAAgF;IAChF,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;CAC9B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,KAAK,GAAG,IAAI,EACnB,SAAS,CAAC,EAAE,MAAM,GACjB,4BAA4B,CAuB9B"}
1
+ {"version":3,"file":"useCanCreateAgentShare.d.ts","sourceRoot":"","sources":["../../src/sharing/useCanCreateAgentShare.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,oDAAoD,CAAC;AAIhF,sDAAsD;AACtD,MAAM,WAAW,4BAA4B;IAC3C,yEAAyE;IACzE,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,gFAAgF;IAChF,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;CAC9B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,KAAK,GAAG,IAAI,EACnB,SAAS,CAAC,EAAE,MAAM,GACjB,4BAA4B,CAuB9B"}
@@ -17,10 +17,11 @@ import { useCheckPermission } from "../iam-policy/useCheckPermission.js";
17
17
  * resource id — an Organization's id equals its slug
18
18
  * (ApiResourceMetadata.id).
19
19
  *
20
- * On the open-source edition both checks reach the server's permissive
21
- * single-team authorizer and answer allowed the same posture the
22
- * backend enforces there (decision 011 D4), so the affordance matches
23
- * what a create would do.
20
+ * On the open-source edition the answer follows the server's posture. A
21
+ * server without sign-in has the permissive single-team authorizer and
22
+ * answers allowed (decision 011 D4). A server with sign-in enforces the
23
+ * same organization roles as the cloud, so `can_create_agent_share` is
24
+ * the admin's and a member sees no share affordance.
24
25
  *
25
26
  * Pass `null` for `agent` while it loads — `allowed` stays `false` so
26
27
  * no affordance flashes before the gate can be evaluated.
@@ -1 +1 @@
1
- {"version":3,"file":"useCanCreateAgentShare.js","sourceRoot":"","sources":["../../src/sharing/useCanCreateAgentShare.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAGb,OAAO,EAAE,qBAAqB,EAAE,MAAM,wDAAwD,CAAC;AAC/F,OAAO,EAAE,kBAAkB,EAAE,MAAM,qCAAqC,CAAC;AAgBzE;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,MAAM,UAAU,sBAAsB,CACpC,KAAmB,EACnB,SAAkB;IAElB,MAAM,OAAO,GAAG,KAAK,EAAE,QAAQ,EAAE,EAAE,IAAI,EAAE,CAAC;IAC1C,MAAM,QAAQ,GAAG,KAAK,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAE,CAAC;IAC5C,MAAM,QAAQ,GAAG,SAAS,IAAI,QAAQ,CAAC;IACvC,MAAM,UAAU,GAAG,QAAQ,KAAK,EAAE,IAAI,QAAQ,KAAK,QAAQ,CAAC;IAC5D,MAAM,QAAQ,GACZ,KAAK,EAAE,QAAQ,EAAE,UAAU,KAAK,qBAAqB,CAAC,iBAAiB,CAAC;IAE1E,oEAAoE;IACpE,oEAAoE;IACpE,MAAM,EAAE,OAAO,EAAE,YAAY,EAAE,GAAG,kBAAkB,CAClD,CAAC,UAAU,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,IAAI,EAC9D,UAAU,CACX,CAAC;IACF,MAAM,EAAE,OAAO,EAAE,cAAc,EAAE,GAAG,kBAAkB,CACpD,UAAU,IAAI,QAAQ,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,cAAc,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,IAAI,EACtE,wBAAwB,CACzB,CAAC;IAEF,MAAM,OAAO,GACX,CAAC,CAAC,KAAK,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,QAAQ,IAAI,cAAc,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC;IAEtE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,CAAC;AAC3C,CAAC"}
1
+ {"version":3,"file":"useCanCreateAgentShare.js","sourceRoot":"","sources":["../../src/sharing/useCanCreateAgentShare.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAGb,OAAO,EAAE,qBAAqB,EAAE,MAAM,wDAAwD,CAAC;AAC/F,OAAO,EAAE,kBAAkB,EAAE,MAAM,qCAAqC,CAAC;AAgBzE;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,MAAM,UAAU,sBAAsB,CACpC,KAAmB,EACnB,SAAkB;IAElB,MAAM,OAAO,GAAG,KAAK,EAAE,QAAQ,EAAE,EAAE,IAAI,EAAE,CAAC;IAC1C,MAAM,QAAQ,GAAG,KAAK,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAE,CAAC;IAC5C,MAAM,QAAQ,GAAG,SAAS,IAAI,QAAQ,CAAC;IACvC,MAAM,UAAU,GAAG,QAAQ,KAAK,EAAE,IAAI,QAAQ,KAAK,QAAQ,CAAC;IAC5D,MAAM,QAAQ,GACZ,KAAK,EAAE,QAAQ,EAAE,UAAU,KAAK,qBAAqB,CAAC,iBAAiB,CAAC;IAE1E,oEAAoE;IACpE,oEAAoE;IACpE,MAAM,EAAE,OAAO,EAAE,YAAY,EAAE,GAAG,kBAAkB,CAClD,CAAC,UAAU,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,IAAI,EAC9D,UAAU,CACX,CAAC;IACF,MAAM,EAAE,OAAO,EAAE,cAAc,EAAE,GAAG,kBAAkB,CACpD,UAAU,IAAI,QAAQ,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,cAAc,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,IAAI,EACtE,wBAAwB,CACzB,CAAC;IAEF,MAAM,OAAO,GACX,CAAC,CAAC,KAAK,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,QAAQ,IAAI,cAAc,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC;IAEtE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,CAAC;AAC3C,CAAC"}
@@ -13,8 +13,10 @@ import {
13
13
  resolveToolCategoryFromKind,
14
14
  extractPrimaryArgFromPreview,
15
15
  extractShellIntentFromPreview,
16
- extractWriteContentFromPreview,
16
+ extractWriteContentFromArgs,
17
17
  isFileCategory,
18
+ parseArgsPreview,
19
+ withAuthoritativeWriteContent,
18
20
  type ToolCategory,
19
21
  } from "./tool-categories.js";
20
22
  import { CATEGORY_ICON } from "./ToolCallItem.js";
@@ -233,6 +235,17 @@ export function ApprovalCardHeader({
233
235
  export interface ApprovalCardBodyProps {
234
236
  /** The pending approval to render a decision panel for. */
235
237
  readonly pendingApproval: PendingApproval;
238
+ /**
239
+ * The gated tool row's authoritative arguments (`ToolCall.args`), when the
240
+ * caller holds the row — the inline gate on a {@link ToolCallItem} does.
241
+ * They supply the proposed write/edit content, which the pending approval's
242
+ * `argsPreview` (the runner's sanitized summary) cannot carry for a file of
243
+ * any real size; every other field the card shows still comes from the
244
+ * preview, so nothing the runner redacted becomes visible. Absent, the card
245
+ * renders from the preview alone and a large write shows the "no preview"
246
+ * notice rather than its content.
247
+ */
248
+ readonly args?: Record<string, unknown>;
236
249
  /** Called when the user picks Approve / Approve-all / Skip / Reject. */
237
250
  readonly onSubmit: (action: ApprovalAction, comment?: string) => void;
238
251
  /** True while the RPC for this tool call is in flight. */
@@ -245,13 +258,16 @@ export interface ApprovalCardBodyProps {
245
258
 
246
259
  /**
247
260
  * The decision panel of an approval: the optional message and gate reason, the
248
- * file-changes / args preview, and the action buttons. Reused in two places —
249
- * the standalone {@link ApprovalCard} (with {@link ApprovalCardHeader} above
250
- * it) and inline inside a gated {@link ToolCallItem}'s expanded panel, where
251
- * the item's own row is the header. One body, identical preview in both.
261
+ * proposed change (the write/edit content from the row's `args` when the caller
262
+ * holds the row, the sanitized args preview otherwise), and the action buttons.
263
+ * Reused in two places the standalone {@link ApprovalCard} (with
264
+ * {@link ApprovalCardHeader} above it) and inline inside a gated
265
+ * {@link ToolCallItem}'s expanded panel, where the item's own row is the header.
266
+ * One body, one preview rule in both.
252
267
  */
253
268
  export function ApprovalCardBody({
254
269
  pendingApproval,
270
+ args,
255
271
  onSubmit,
256
272
  isSubmitting = false,
257
273
  error = null,
@@ -279,18 +295,17 @@ export function ApprovalCardBody({
279
295
  pendingApproval.mcpServerSlug,
280
296
  );
281
297
 
282
- const parsedArgs = useMemo<Record<string, unknown> | null>(() => {
283
- if (!pendingApproval.argsPreview) return null;
284
- try {
285
- const parsed = JSON.parse(pendingApproval.argsPreview);
286
- if (typeof parsed === "object" && parsed !== null) {
287
- return parsed as Record<string, unknown>;
288
- }
289
- return null;
290
- } catch {
291
- return null;
292
- }
293
- }, [pendingApproval.argsPreview]);
298
+ // What the card shows: the preview's fields (the runner's sanitized summary),
299
+ // with the proposed write/edit content overlaid from the row's authoritative
300
+ // `args` when the caller holds the row — the one value the summary cannot
301
+ // carry for a file of any real size, and the one the user is asked to approve.
302
+ // A preview that does not parse (a record persisted under an older runner's
303
+ // whole-string truncation) stays `null`: nothing is overlaid onto a summary
304
+ // the card cannot read, and the notice below says so honestly.
305
+ const displayArgs = useMemo<Record<string, unknown> | null>(() => {
306
+ const previewArgs = parseArgsPreview(pendingApproval.argsPreview);
307
+ return previewArgs ? withAuthoritativeWriteContent(previewArgs, args) : null;
308
+ }, [pendingApproval.argsPreview, args]);
294
309
 
295
310
  // APPROVE_ALL now grants a run-lifetime lease scoped to the clicked tool's
296
311
  // CLASS — its MCP server for an MCP tool, else its approval category (where
@@ -327,11 +342,11 @@ export function ApprovalCardBody({
327
342
  const isWriteEdit =
328
343
  categoryInfo.category === "write" || categoryInfo.category === "edit";
329
344
 
330
- // The proposed write/edit content from the args preview, when the gate carries
331
- // no authoritative file_changes capture. Drives the three-way fallback below.
345
+ // The proposed write/edit content, when the gate carries no authoritative
346
+ // file_changes capture. Drives the three-way fallback below.
332
347
  const writeContent = useMemo(
333
- () => extractWriteContentFromPreview(pendingApproval.argsPreview),
334
- [pendingApproval.argsPreview],
348
+ () => (displayArgs ? extractWriteContentFromArgs(displayArgs) : null),
349
+ [displayArgs],
335
350
  );
336
351
 
337
352
  // The decision-relevant preview. The pre-execution deny-gate is an
@@ -353,11 +368,11 @@ export function ApprovalCardBody({
353
368
  const kind =
354
369
  pendingApproval.toolKind === ToolKind.FILE_WRITE ? "create" : "no-preview";
355
370
  preview = <EmptyChangeNotice kind={kind} />;
356
- } else if (parsedArgs) {
371
+ } else if (displayArgs) {
357
372
  preview = (
358
373
  <ToolArgsView
359
374
  toolName={pendingApproval.toolName}
360
- args={parsedArgs}
375
+ args={displayArgs}
361
376
  mcpServerSlug={pendingApproval.mcpServerSlug}
362
377
  showFileName={!isWriteEdit}
363
378
  />
@@ -4,7 +4,7 @@ import { useMemo } from "react";
4
4
  import { cn } from "@stigmer/theme";
5
5
  import {
6
6
  resolveToolCategory,
7
- extractWriteContentFromPreview,
7
+ extractWriteContentFromArgs,
8
8
  } from "./tool-categories.js";
9
9
  import type { ToolCategory, ToolCategoryInfo } from "./tool-categories.js";
10
10
  import { FilePathLink } from "./FilePathLink.js";
@@ -239,25 +239,6 @@ function GenericArgsView({ args }: { args: Record<string, unknown> }) {
239
239
  // Utilities
240
240
  // ---------------------------------------------------------------------------
241
241
 
242
- const WRITE_CONTENT_FIELDS = [
243
- "contents",
244
- "content",
245
- "file_content",
246
- "new_text",
247
- "new_string",
248
- "replacement",
249
- ] as const;
250
-
251
- function extractWriteContentFromArgs(
252
- args: Record<string, unknown>,
253
- ): string | null {
254
- for (const field of WRITE_CONTENT_FIELDS) {
255
- const val = args[field];
256
- if (typeof val === "string" && val.length > 0) return val;
257
- }
258
- return null;
259
- }
260
-
261
242
  function extractPrimaryArgValue(
262
243
  args: Record<string, unknown> | null,
263
244
  info: ToolCategoryInfo,
@@ -360,8 +360,12 @@ export const ToolCallItem = memo(function ToolCallItem({
360
360
  // The row above is this approval's header, so render only the body (preview
361
361
  // + actions). The enclosing card already carries the border + warning/
362
362
  // destructive accent for a pending gate, so the body renders borderless.
363
+ // This row holds the call's authoritative `args`; the body reads the
364
+ // proposed write content from them, which the approval's sanitized preview
365
+ // cannot carry for a file of any real size (stigmer#1107).
363
366
  <ApprovalCardBody
364
367
  pendingApproval={approval.pendingApproval}
368
+ args={toolCall.args}
365
369
  onSubmit={approval.onSubmit}
366
370
  isSubmitting={approval.isSubmitting}
367
371
  error={approval.error}
@@ -7,7 +7,7 @@ import {
7
7
  ApprovalPolicySource,
8
8
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
9
9
  import { ToolKind } from "@stigmer/sdk";
10
- import { ApprovalCard } from "../ApprovalCard";
10
+ import { ApprovalCard, ApprovalCardBody } from "../ApprovalCard";
11
11
 
12
12
  // Under apply-then-review the deny-gate carries no captured `file_changes`
13
13
  // (message.proto field 14 was removed in Phase 5 Slice 4); the gate renders the
@@ -451,6 +451,115 @@ describe("ApprovalCard file-change preview", () => {
451
451
  });
452
452
  });
453
453
 
454
+ // ---------------------------------------------------------------------------
455
+ // The proposed content comes from the row's authoritative args (stigmer#1107)
456
+ //
457
+ // `argsPreview` is the runner's sanitized summary: a value over its per-field
458
+ // cap is not carried whole (older runners wrote an in-band `[N chars]` marker;
459
+ // current ones leave the field out). The body therefore reads the proposed
460
+ // write/edit content from the row's `args` when the caller holds the row, and
461
+ // only that — every other field still renders from the redacted preview.
462
+ // ---------------------------------------------------------------------------
463
+
464
+ describe("ApprovalCardBody proposed content from args", () => {
465
+ const BIG = Array.from({ length: 30 }, (_, i) => `gate line ${i + 1}`).join("\n") + "\n";
466
+
467
+ function bigWriteApproval(argsPreview: string) {
468
+ return create(PendingApprovalSchema, {
469
+ toolCallId: "tc-big",
470
+ toolName: "write_file",
471
+ toolKind: ToolKind.FILE_WRITE,
472
+ argsPreview,
473
+ });
474
+ }
475
+
476
+ it("renders the whole content from args when the preview carried an elision marker (older runner)", () => {
477
+ render(
478
+ <ApprovalCardBody
479
+ pendingApproval={bigWriteApproval('{"file_path":"/tmp/big.txt","content":"[381 chars]"}')}
480
+ args={{ file_path: "/tmp/big.txt", content: BIG }}
481
+ onSubmit={noop}
482
+ />,
483
+ );
484
+
485
+ expect(screen.getByText("Content")).toBeTruthy();
486
+ // The head renders, bounded by lines, with the in-place reveal; the marker
487
+ // is never shown as if it were the file.
488
+ expect(screen.getByText(/gate line 1\b/)).toBeTruthy();
489
+ expect(screen.queryByText(/gate line 30/)).toBeNull();
490
+ expect(screen.getByRole("button", { name: /Show all \d+ lines/ })).toBeTruthy();
491
+ expect(screen.queryByText(/\[381 chars\]/)).toBeNull();
492
+ expect(screen.queryByText("New file — preview unavailable")).toBeNull();
493
+ });
494
+
495
+ it("renders the whole content from args when the preview left the field out (current runner)", () => {
496
+ render(
497
+ <ApprovalCardBody
498
+ pendingApproval={bigWriteApproval('{"file_path":"/tmp/big.txt"}')}
499
+ args={{ file_path: "/tmp/big.txt", content: BIG }}
500
+ onSubmit={noop}
501
+ />,
502
+ );
503
+
504
+ expect(screen.getByText("Content")).toBeTruthy();
505
+ expect(screen.getByText(/gate line 1\b/)).toBeTruthy();
506
+ expect(screen.queryByText("New file — preview unavailable")).toBeNull();
507
+ });
508
+
509
+ it("without the row, a write whose content the preview left out shows the honest notice", () => {
510
+ // The row-less surfaces (the workflow approval list, an embedded card):
511
+ // nothing is invented from a preview that carries no content.
512
+ render(
513
+ <ApprovalCardBody
514
+ pendingApproval={bigWriteApproval('{"file_path":"/tmp/big.txt"}')}
515
+ onSubmit={noop}
516
+ />,
517
+ );
518
+
519
+ expect(screen.getByText("New file — preview unavailable")).toBeTruthy();
520
+ expect(screen.queryByText("Content")).toBeNull();
521
+ });
522
+
523
+ it("overlays the content only — a field the preview redacted stays redacted when args carry it raw", () => {
524
+ // An MCP gate renders every scalar argument, so this is where a raw value
525
+ // crossing over from `args` would show. It must not.
526
+ const approval = create(PendingApprovalSchema, {
527
+ toolCallId: "tc-mcp-secret",
528
+ toolName: "publish",
529
+ toolKind: ToolKind.MCP,
530
+ mcpServerSlug: "notes",
531
+ argsPreview: '{"title":"hello","token":"[REDACTED]"}',
532
+ });
533
+
534
+ render(
535
+ <ApprovalCardBody
536
+ pendingApproval={approval}
537
+ args={{ title: "hello", token: "s3cr3t-raw" }}
538
+ onSubmit={noop}
539
+ />,
540
+ );
541
+
542
+ expect(screen.getByText("[REDACTED]")).toBeTruthy();
543
+ expect(screen.queryByText(/s3cr3t-raw/)).toBeNull();
544
+ });
545
+
546
+ it("does not overlay onto a preview the card cannot parse (a record from an older runner's truncation)", () => {
547
+ // Pre-unification native previews truncated the whole JSON string at 500
548
+ // chars; such a record's preview does not parse. The body keeps today's
549
+ // honest notice rather than rendering a content block with no path row.
550
+ render(
551
+ <ApprovalCardBody
552
+ pendingApproval={bigWriteApproval('{"file_path":"/tmp/big.txt","content":"gate li…')}
553
+ args={{ file_path: "/tmp/big.txt", content: BIG }}
554
+ onSubmit={noop}
555
+ />,
556
+ );
557
+
558
+ expect(screen.getByText("New file — preview unavailable")).toBeTruthy();
559
+ expect(screen.queryByText("Content")).toBeNull();
560
+ });
561
+ });
562
+
454
563
  describe("ApprovalCard in-card decision error", () => {
455
564
  function makeApproval() {
456
565
  return create(PendingApprovalSchema, {
@@ -494,6 +494,44 @@ describe("ToolCallItem disclosure", () => {
494
494
  expect(row.className).not.toContain("stg:bg-warning");
495
495
  });
496
496
 
497
+ it("shows a gated write's whole proposed content from the row's args, never the preview's elision marker (stigmer#1107)", () => {
498
+ // The row holds the authoritative `args`; the approval's `argsPreview` is
499
+ // the runner's sanitized summary and cannot carry a file of any real size
500
+ // (an older runner wrote `[N chars]`; the current one leaves the field
501
+ // out). The inline gate must read the content from the row.
502
+ const big = Array.from({ length: 30 }, (_, i) => `gate line ${i + 1}`).join("\n") + "\n";
503
+ const tc = makeToolCall({
504
+ id: "tc-big-write",
505
+ name: "write_file",
506
+ args: { file_path: "/tmp/e2e-big-gate.txt", content: big },
507
+ status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
508
+ });
509
+ const approval: PendingApproval = create(PendingApprovalSchema, {
510
+ toolCallId: "tc-big-write",
511
+ toolName: "write_file",
512
+ argsPreview: '{"file_path":"/tmp/e2e-big-gate.txt","content":"[381 chars]"}',
513
+ });
514
+ const ctx: ApprovalContextValue = {
515
+ approvalsByToolCallId: new Map([["tc-big-write", approval]]),
516
+ onSubmit: () => {},
517
+ submittingIds: new Set(),
518
+ errorsByToolCallId: new Map(),
519
+ };
520
+
521
+ const { container } = render(
522
+ <ApprovalContext.Provider value={ctx}>
523
+ <ToolCallItem toolCall={tc} />
524
+ </ApprovalContext.Provider>,
525
+ );
526
+
527
+ const row = container.querySelector('[data-cursor-target="tool-call-row"]')!;
528
+ expect(row.textContent).toContain("gate line 1");
529
+ expect(row.textContent).not.toContain("gate line 30"); // bounded by lines
530
+ expect(row.textContent).not.toContain("[381 chars]");
531
+ expect(screen.getByRole("button", { name: /Show all \d+ lines/ })).toBeTruthy();
532
+ expect(screen.getByLabelText("Approve")).toBeTruthy();
533
+ });
534
+
497
535
  it("routes an inline APPROVE_ALL decision with the gated tool's id", () => {
498
536
  const tc = makeToolCall({
499
537
  id: "tc-gated",
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The one write-content rule for the gate and the settled row (stigmer#1107):
3
+ * which argument field carries a write's proposed content, and how the gate
4
+ * overlays that content from the row's authoritative `args` onto the
5
+ * sanitized preview without letting any other field cross over.
6
+ */
7
+ import { describe, it, expect } from "vitest";
8
+ import {
9
+ extractWriteContentFromArgs,
10
+ parseArgsPreview,
11
+ withAuthoritativeWriteContent,
12
+ } from "../tool-categories";
13
+
14
+ describe("extractWriteContentFromArgs", () => {
15
+ it("returns the first content field holding a non-empty string, in list order", () => {
16
+ expect(extractWriteContentFromArgs({ contents: "a", content: "b" })).toBe("a");
17
+ expect(extractWriteContentFromArgs({ content: "", new_string: "b" })).toBe("b");
18
+ expect(extractWriteContentFromArgs({ new_text: "cursor", replacement: "x" })).toBe("cursor");
19
+ });
20
+
21
+ it("returns null when no content field holds a string", () => {
22
+ expect(extractWriteContentFromArgs({ file_path: "/x" })).toBeNull();
23
+ expect(extractWriteContentFromArgs({ content: 42 })).toBeNull();
24
+ expect(extractWriteContentFromArgs({})).toBeNull();
25
+ });
26
+ });
27
+
28
+ describe("parseArgsPreview", () => {
29
+ it("parses an object preview and rejects everything else", () => {
30
+ expect(parseArgsPreview('{"path":"/x"}')).toEqual({ path: "/x" });
31
+ expect(parseArgsPreview("")).toBeNull();
32
+ expect(parseArgsPreview('"a string"')).toBeNull();
33
+ expect(parseArgsPreview("[1,2]")).toBeNull();
34
+ expect(parseArgsPreview('{"path":"/x","content":"trunc…')).toBeNull();
35
+ });
36
+ });
37
+
38
+ describe("withAuthoritativeWriteContent", () => {
39
+ const preview = { file_path: "/x", token: "[REDACTED]", content: "[381 chars]" };
40
+
41
+ it("replaces an elided content value under the field args carry it in", () => {
42
+ const out = withAuthoritativeWriteContent(preview, {
43
+ file_path: "/x",
44
+ token: "raw-secret",
45
+ content: "the whole file\n",
46
+ });
47
+ expect(out.content).toBe("the whole file\n");
48
+ });
49
+
50
+ it("adds a content field the preview left out", () => {
51
+ const out = withAuthoritativeWriteContent(
52
+ { file_path: "/x" },
53
+ { file_path: "/x", new_string: "after" },
54
+ );
55
+ expect(out).toEqual({ file_path: "/x", new_string: "after" });
56
+ });
57
+
58
+ it("copies nothing but the content: a redacted field stays redacted", () => {
59
+ const out = withAuthoritativeWriteContent(preview, {
60
+ file_path: "/y",
61
+ token: "raw-secret",
62
+ content: "c",
63
+ extra: "never shown",
64
+ });
65
+ expect(out.token).toBe("[REDACTED]");
66
+ expect(out.file_path).toBe("/x");
67
+ expect("extra" in out).toBe(false);
68
+ });
69
+
70
+ it("returns the preview object itself when args carry no content, or are absent", () => {
71
+ expect(withAuthoritativeWriteContent(preview, { file_path: "/x" })).toBe(preview);
72
+ expect(withAuthoritativeWriteContent(preview, undefined)).toBe(preview);
73
+ });
74
+
75
+ it("never mutates its inputs", () => {
76
+ const args = { file_path: "/x", content: "c" };
77
+ const out = withAuthoritativeWriteContent(preview, args);
78
+ expect(out).not.toBe(preview);
79
+ expect(preview.content).toBe("[381 chars]");
80
+ expect(args.content).toBe("c");
81
+ });
82
+ });
@@ -545,6 +545,14 @@ export function extractShellIntentFromPreview(
545
545
  }
546
546
  }
547
547
 
548
+ /**
549
+ * The argument fields under which a write or edit tool carries the content it
550
+ * proposes, in the order they are tried. One list for every reader — the
551
+ * pre-execution gate ({@link ApprovalCardBody}) and the post-execution row
552
+ * ({@link ToolArgsView}) — so the two never disagree on what "the content" is.
553
+ * Spans both harnesses' file-tool shapes (deepagents' `content`/`new_string`,
554
+ * Cursor's `contents`/`new_text`).
555
+ */
548
556
  const WRITE_CONTENT_FIELDS = [
549
557
  "contents",
550
558
  "content",
@@ -554,29 +562,81 @@ const WRITE_CONTENT_FIELDS = [
554
562
  "replacement",
555
563
  ] as const;
556
564
 
565
+ /** A proposed write's content and the argument field it was found under. */
566
+ interface WriteContent {
567
+ readonly field: (typeof WRITE_CONTENT_FIELDS)[number];
568
+ readonly value: string;
569
+ }
570
+
557
571
  /**
558
- * Extracts the file content body from a JSON `argsPreview` string
559
- * for write/edit tool categories. Scans the same field names used
560
- * by the post-execution {@link ToolCallDetail} renderer so that
561
- * the approval preview matches the completed tool call display.
562
- *
563
- * Returns `null` if parsing fails or no content field is found.
572
+ * Finds the content a write/edit tool proposes in its parsed arguments: the
573
+ * first {@link WRITE_CONTENT_FIELDS} entry holding a non-empty string, with the
574
+ * field it sat under. `null` when the args carry no content.
564
575
  */
565
- export function extractWriteContentFromPreview(
566
- argsPreview: string,
576
+ function findWriteContent(
577
+ args: Record<string, unknown>,
578
+ ): WriteContent | null {
579
+ for (const field of WRITE_CONTENT_FIELDS) {
580
+ const value = args[field];
581
+ if (typeof value === "string" && value.length > 0) return { field, value };
582
+ }
583
+ return null;
584
+ }
585
+
586
+ /**
587
+ * The content a write/edit tool proposes, from its parsed arguments, or `null`.
588
+ * The value half of {@link findWriteContent}.
589
+ */
590
+ export function extractWriteContentFromArgs(
591
+ args: Record<string, unknown>,
567
592
  ): string | null {
568
- if (!argsPreview) return null;
593
+ return findWriteContent(args)?.value ?? null;
594
+ }
569
595
 
596
+ /**
597
+ * Parses a JSON `argsPreview` string into an args object, or `null` when the
598
+ * string is empty, unparseable, or not an object. The preview is the runner's
599
+ * sanitized summary of a tool call's arguments — secrets redacted, a value too
600
+ * large to carry whole left out, paths and commands kept verbatim — so what
601
+ * this returns is what a surface may SHOW; the proposed content of a write is
602
+ * read from the row's `args` (see {@link withAuthoritativeWriteContent}).
603
+ */
604
+ export function parseArgsPreview(
605
+ argsPreview: string,
606
+ ): Record<string, unknown> | null {
607
+ if (!argsPreview) return null;
570
608
  try {
571
- const parsed = JSON.parse(argsPreview);
572
- if (typeof parsed !== "object" || parsed === null) return null;
573
-
574
- for (const field of WRITE_CONTENT_FIELDS) {
575
- const val = (parsed as Record<string, unknown>)[field];
576
- if (typeof val === "string" && val.length > 0) return val;
577
- }
578
- return null;
609
+ const parsed: unknown = JSON.parse(argsPreview);
610
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
611
+ ? (parsed as Record<string, unknown>)
612
+ : null;
579
613
  } catch {
580
614
  return null;
581
615
  }
582
616
  }
617
+
618
+ /**
619
+ * The preview-parsed args of a gated tool call with the proposed write content
620
+ * taken from the row's authoritative `args` — and only that.
621
+ *
622
+ * A tool row carries two projections of its arguments: `args`, the full
623
+ * arguments, and `args_preview`, the runner's sanitized summary (secrets
624
+ * redacted, oversized values left out, paths and commands kept whole). The
625
+ * summary is the right thing to *show* for every field but one: the content a
626
+ * write or edit proposes, which is exactly the value the summary cannot carry
627
+ * and exactly what the user is asked to approve. So the gate renders the
628
+ * preview's fields and overlays the content from `args`, under the field
629
+ * `args` carries it in. Nothing else crosses over — a field the runner
630
+ * redacted in the preview stays redacted here.
631
+ *
632
+ * Returns `previewArgs` itself when `args` carries no content (nothing to
633
+ * overlay) so memoized consumers keep their reference.
634
+ */
635
+ export function withAuthoritativeWriteContent(
636
+ previewArgs: Record<string, unknown>,
637
+ args: Record<string, unknown> | undefined,
638
+ ): Record<string, unknown> {
639
+ const content = args ? findWriteContent(args) : null;
640
+ if (!content) return previewArgs;
641
+ return { ...previewArgs, [content.field]: content.value };
642
+ }
@@ -54,7 +54,9 @@ export interface OrgSwitcherProps {
54
54
  *
55
55
  * Designed for sidebar placement — the trigger uses `sidebar-*` design
56
56
  * tokens. The portaled dropdown and dialog use standard `popover-*` /
57
- * main-area tokens per theme-token-guidelines.
57
+ * main-area tokens, because portaled content renders outside the sidebar's
58
+ * DOM tree (the theme-token reference in the repository's
59
+ * sdk-console-architecture skill).
58
60
  *
59
61
  * Must be rendered inside an {@link OrgProvider}.
60
62
  *
@@ -34,10 +34,11 @@ export interface UseCanCreateAgentShareReturn {
34
34
  * resource id — an Organization's id equals its slug
35
35
  * (ApiResourceMetadata.id).
36
36
  *
37
- * On the open-source edition both checks reach the server's permissive
38
- * single-team authorizer and answer allowed the same posture the
39
- * backend enforces there (decision 011 D4), so the affordance matches
40
- * what a create would do.
37
+ * On the open-source edition the answer follows the server's posture. A
38
+ * server without sign-in has the permissive single-team authorizer and
39
+ * answers allowed (decision 011 D4). A server with sign-in enforces the
40
+ * same organization roles as the cloud, so `can_create_agent_share` is
41
+ * the admin's and a member sees no share affordance.
41
42
  *
42
43
  * Pass `null` for `agent` while it loads — `allowed` stays `false` so
43
44
  * no affordance flashes before the gate can be evaluated.
@@ -90,7 +90,9 @@ export interface UserMenuProps {
90
90
  *
91
91
  * Designed for sidebar placement — the trigger uses `sidebar-*` design
92
92
  * tokens. The portaled dropdown uses standard `popover-*` / main-area
93
- * tokens per theme-token-guidelines (DD-005).
93
+ * tokens, because portaled content renders outside the sidebar's DOM tree
94
+ * (the theme-token reference in the repository's sdk-console-architecture
95
+ * skill).
94
96
  *
95
97
  * Framework-agnostic: all actions are expressed as callback props
96
98
  * (DD-004). The consumer bridges to their routing, auth, and theme