@stigmer/react 3.0.9-dev.20260615145121 → 3.0.9-dev.20260615153829

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 (52) hide show
  1. package/execution/ArtifactContentRenderer.d.ts.map +1 -1
  2. package/execution/ArtifactContentRenderer.js +7 -3
  3. package/execution/ArtifactContentRenderer.js.map +1 -1
  4. package/execution/ArtifactPreviewModal.d.ts +3 -3
  5. package/execution/ArtifactPreviewModal.js +4 -4
  6. package/execution/ArtifactPreviewModal.js.map +1 -1
  7. package/execution/MessageEntry.d.ts.map +1 -1
  8. package/execution/MessageEntry.js +7 -3
  9. package/execution/MessageEntry.js.map +1 -1
  10. package/execution/PlanArtifactCard.d.ts +25 -14
  11. package/execution/PlanArtifactCard.d.ts.map +1 -1
  12. package/execution/PlanArtifactCard.js +25 -10
  13. package/execution/PlanArtifactCard.js.map +1 -1
  14. package/execution/PlanCompletionCard.d.ts +12 -9
  15. package/execution/PlanCompletionCard.d.ts.map +1 -1
  16. package/execution/PlanCompletionCard.js +14 -9
  17. package/execution/PlanCompletionCard.js.map +1 -1
  18. package/execution/use-build-from-plan-hotkey.d.ts +19 -0
  19. package/execution/use-build-from-plan-hotkey.d.ts.map +1 -0
  20. package/execution/use-build-from-plan-hotkey.js +30 -0
  21. package/execution/use-build-from-plan-hotkey.js.map +1 -0
  22. package/internal/markdown-components.d.ts +18 -0
  23. package/internal/markdown-components.d.ts.map +1 -1
  24. package/internal/markdown-components.js +33 -0
  25. package/internal/markdown-components.js.map +1 -1
  26. package/package.json +4 -4
  27. package/portal-container.d.ts +9 -6
  28. package/portal-container.d.ts.map +1 -1
  29. package/portal-container.js +9 -6
  30. package/portal-container.js.map +1 -1
  31. package/provider.d.ts.map +1 -1
  32. package/provider.js +41 -19
  33. package/provider.js.map +1 -1
  34. package/src/__tests__/provider-theme-scope.test.tsx +94 -0
  35. package/src/execution/ArtifactContentRenderer.tsx +11 -2
  36. package/src/execution/ArtifactPreviewModal.tsx +7 -7
  37. package/src/execution/MessageEntry.tsx +14 -3
  38. package/src/execution/PlanArtifactCard.tsx +60 -51
  39. package/src/execution/PlanCompletionCard.tsx +20 -13
  40. package/src/execution/__tests__/ArtifactContentRenderer.test.tsx +47 -0
  41. package/src/execution/__tests__/ArtifactPreviewModal.test.tsx +6 -6
  42. package/src/execution/__tests__/MessageThread.test.tsx +3 -3
  43. package/src/execution/__tests__/PlanArtifactCard.test.tsx +120 -31
  44. package/src/execution/__tests__/PlanCompletionCard.test.tsx +31 -2
  45. package/src/execution/__tests__/message-entry.test.tsx +27 -0
  46. package/src/execution/use-build-from-plan-hotkey.ts +39 -0
  47. package/src/internal/__tests__/markdown-components.test.tsx +53 -0
  48. package/src/internal/markdown-components.tsx +36 -0
  49. package/src/portal-container.ts +9 -6
  50. package/src/provider.tsx +70 -23
  51. package/src/session/inspector/__tests__/ArtifactsTab.test.tsx +7 -7
  52. package/styles.css +1 -1
@@ -0,0 +1,39 @@
1
+ "use client";
2
+
3
+ import { useCallback, type KeyboardEvent } from "react";
4
+
5
+ /**
6
+ * Card-scoped `Cmd/Ctrl+Enter` accelerator for the "Build from plan" action.
7
+ *
8
+ * Returns an `onKeyDown` handler for a plan card's root element. Because it is
9
+ * attached to the card (not `window`), it fires only when the keystroke
10
+ * originates from within the card — so the SDK never installs a global listener
11
+ * that could hijack a host application's keyboard shortcuts (an embeddable-a11y
12
+ * requirement). The card's primary button stays natively activatable with
13
+ * Enter/Space when focused; this adds the power-user accelerator on top.
14
+ *
15
+ * No-op when there is no action wired or the action is disabled.
16
+ *
17
+ * Shared by {@link PlanArtifactCard} and {@link PlanCompletionCard} so the
18
+ * accelerator's behavior lives in exactly one place and cannot drift between
19
+ * the two cards.
20
+ */
21
+ export function useBuildFromPlanHotkey(
22
+ onImplement: (() => void) | undefined,
23
+ disabled: boolean | undefined,
24
+ ): (event: KeyboardEvent<HTMLElement>) => void {
25
+ return useCallback(
26
+ (event) => {
27
+ if (
28
+ onImplement &&
29
+ !disabled &&
30
+ event.key === "Enter" &&
31
+ (event.metaKey || event.ctrlKey)
32
+ ) {
33
+ event.preventDefault();
34
+ onImplement();
35
+ }
36
+ },
37
+ [onImplement, disabled],
38
+ );
39
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { unwrapEnclosingMarkdownFence } from "../markdown-components";
3
+
4
+ describe("unwrapEnclosingMarkdownFence", () => {
5
+ describe("unwraps a whole-message markdown fence", () => {
6
+ it.each([
7
+ ["```markdown tag", "```markdown\n# Plan\n- step\n```", "# Plan\n- step"],
8
+ ["```md tag", "```md\n# Plan\n```", "# Plan"],
9
+ ["case-insensitive tag", "```Markdown\n# Plan\n```", "# Plan"],
10
+ ["uppercase md", "```MD\n# Plan\n```", "# Plan"],
11
+ [
12
+ "tilde-free body with inner code fence preserved",
13
+ "```markdown\n# Plan\n```ts\nconst x = 1;\n```\n```",
14
+ "# Plan\n```ts\nconst x = 1;\n```",
15
+ ],
16
+ [
17
+ "surrounding whitespace is ignored",
18
+ "\n\n```markdown\n# Plan\n```\n\n",
19
+ "# Plan",
20
+ ],
21
+ [
22
+ "longer-than-three backtick fence",
23
+ "````markdown\n# Plan\n````",
24
+ "# Plan",
25
+ ],
26
+ ])("%s", (_label, input, expected) => {
27
+ expect(unwrapEnclosingMarkdownFence(input)).toBe(expected);
28
+ });
29
+ });
30
+
31
+ describe("leaves everything else untouched (returns the original)", () => {
32
+ it.each([
33
+ ["plain markdown (no fence)", "# Plan\n\n- step one\n- step two"],
34
+ ["a bare ``` fence (ambiguous — could be code)", "```\n# Plan\n```"],
35
+ ["a language-tagged code block", "```js\nconsole.log('hi');\n```"],
36
+ ["an unclosed fence (mid-stream)", "```markdown\n# Plan in progr"],
37
+ ["trailing content after the fence", "```markdown\n# Plan\n```\nthen more"],
38
+ ["leading content before the fence", "intro\n```markdown\n# Plan\n```"],
39
+ ["a richer info string than markdown", "```markdown title\n# Plan\n```"],
40
+ ["an empty fenced block", "```markdown\n```"],
41
+ ["plain prose", "Here is the plan you asked for."],
42
+ ["empty string", ""],
43
+ ])("%s", (_label, input) => {
44
+ expect(unwrapEnclosingMarkdownFence(input)).toBe(input);
45
+ });
46
+ });
47
+
48
+ it("is idempotent — a once-unwrapped plan is left alone", () => {
49
+ const wrapped = "```markdown\n# Plan\n- a\n```";
50
+ const once = unwrapEnclosingMarkdownFence(wrapped);
51
+ expect(unwrapEnclosingMarkdownFence(once)).toBe(once);
52
+ });
53
+ });
@@ -26,6 +26,42 @@ export function stripFrontmatter(content: string): string {
26
26
  return content.replace(FRONTMATTER_RE, "");
27
27
  }
28
28
 
29
+ /**
30
+ * Matches content whose ENTIRE body is a single fenced code block tagged
31
+ * `markdown` / `md`. Capture group 1 is the opening backtick run (so the close
32
+ * must use the same run via the `\1` backreference); group 2 is the inner body.
33
+ *
34
+ * Deliberately strict: the info string must be exactly `markdown`/`md` and the
35
+ * fence must span the whole (trimmed) string. A bare ``` ``` ``` fence is NOT
36
+ * matched — without the explicit language tag we cannot tell wrapped markdown
37
+ * from a legitimate single code block, and guessing by inspecting the body is
38
+ * the kind of fuzzy heuristic this codebase avoids.
39
+ */
40
+ const ENCLOSING_MARKDOWN_FENCE_RE =
41
+ /^(`{3,})[ \t]*(?:markdown|md)[ \t]*\r?\n([\s\S]*?)\r?\n\1[ \t]*$/i;
42
+
43
+ /**
44
+ * Unwraps a message the model wrapped entirely in a ```markdown / ```md fence.
45
+ *
46
+ * Some models emit their whole markdown reply inside one fenced block (a
47
+ * Plan-mode plan is the common case). Rendered as-is that becomes a single flat
48
+ * code block instead of rich markdown — headings, lists, and tables collapse to
49
+ * monospace text. This returns the inner markdown in exactly that case and is a
50
+ * no-op for everything else (already-rich markdown, prose, or a reply that is
51
+ * legitimately a single code block).
52
+ *
53
+ * Render-time only: callers pass it the text right before handing it to the
54
+ * markdown renderer, so the transcript and the raw artifact stay faithful to
55
+ * what the agent produced — a single source of truth for the unwrap, with no
56
+ * duplicated logic in the runner. While streaming, the closing fence has not
57
+ * arrived yet, so this no-ops and the live text renders as typed; it unwraps
58
+ * once the block closes.
59
+ */
60
+ export function unwrapEnclosingMarkdownFence(content: string): string {
61
+ const match = ENCLOSING_MARKDOWN_FENCE_RE.exec(content.trim());
62
+ return match ? match[2] : content;
63
+ }
64
+
29
65
  /**
30
66
  * Styled react-markdown component overrides for SDK markdown surfaces.
31
67
  *
@@ -5,12 +5,15 @@ import { createContext, useContext } from "react";
5
5
  /**
6
6
  * React context that holds a reference to the managed portal container.
7
7
  *
8
- * `StigmerProvider` creates a `<div>` appended to `document.body` with
9
- * the same scoping attributes (`class="stgm [preset]"`,
10
- * `data-stgm-color-mode`) as the main provider container. This ensures
11
- * that portaled content (popovers, dialogs, menus) inherits the correct
12
- * design token values including dark-mode overrides even though it
13
- * lives outside the provider's DOM subtree.
8
+ * `StigmerProvider` creates a `<div>` appended to `document.body` that
9
+ * carries the identical theme scope as the main provider container —
10
+ * the same class (`stgm` + preset class + any host `className`) and the
11
+ * same `data-stgm-color-mode`. Both containers derive this from a single
12
+ * source (`useThemeScope` in `provider.tsx`), so they cannot drift apart.
13
+ * This ensures portaled content (popovers, dialogs, menus) inherits the
14
+ * correct design token values — including dark-mode overrides and host
15
+ * `className`-scoped token overrides — even though it lives outside the
16
+ * provider's DOM subtree (where the cascade would otherwise not reach it).
14
17
  *
15
18
  * Defaults to `null` so components rendered outside a `StigmerProvider`
16
19
  * fall back to the browser's default portal target (`document.body`).
package/src/provider.tsx CHANGED
@@ -1,6 +1,13 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type ReactNode,
10
+ } from "react";
4
11
  import type { Stigmer, DeploymentMode } from "@stigmer/sdk";
5
12
  import { cn, resolvePresetClass } from "@stigmer/theme";
6
13
  import type { ThemePresetId } from "@stigmer/theme";
@@ -174,7 +181,8 @@ export function StigmerProvider({
174
181
 
175
182
  const presetClass = preset ? resolvePresetClass(preset) : "";
176
183
 
177
- const portalContainer = usePortalContainer(resolvedMode, presetClass);
184
+ const scope = useThemeScope(resolvedMode, presetClass, className);
185
+ const portalContainer = usePortalContainer(scope);
178
186
  const registryState = useModelRegistryFetch(client);
179
187
  const taskKindRegistryState = useTaskKindRegistryFetch(client);
180
188
 
@@ -188,8 +196,8 @@ export function StigmerProvider({
188
196
  <TaskKindRegistryContext.Provider value={taskKindRegistryState}>
189
197
  <PortalContainerContext.Provider value={portalContainer}>
190
198
  <div
191
- className={cn("stgm", presetClass, className)}
192
- data-stgm-color-mode={resolvedMode}
199
+ className={scope.className}
200
+ data-stgm-color-mode={scope.colorMode}
193
201
  >
194
202
  {children}
195
203
  </div>
@@ -480,33 +488,72 @@ function useTaskKindRegistryFetch(client: Stigmer): TaskKindRegistryState {
480
488
  return { ...state, refetch };
481
489
  }
482
490
 
491
+ /**
492
+ * The single scoping contract every Stigmer surface must carry so that
493
+ * `--stgm-*` tokens, preset overrides, dark-mode values, AND host
494
+ * `className` overrides resolve identically — whether a surface renders
495
+ * inline in the provider subtree or is portaled onto `document.body`.
496
+ */
497
+ interface ThemeScope {
498
+ /** Class string: `stgm` + preset class + host `className`. */
499
+ readonly className: string;
500
+ /** Resolved color mode set as `data-stgm-color-mode`. */
501
+ readonly colorMode: ResolvedColorMode;
502
+ }
503
+
504
+ /**
505
+ * Computes the {@link ThemeScope} once per render input.
506
+ *
507
+ * Both the in-tree provider container and the managed portal container
508
+ * derive their class + `data-stgm-color-mode` from this single source,
509
+ * so the two can never drift apart — the cause of #182, where the portal
510
+ * container omitted the host `className` and portaled surfaces (popovers,
511
+ * dialogs, menus) lost host token overrides scoped to that class.
512
+ *
513
+ * The returned object is referentially stable until an input changes,
514
+ * which keeps {@link usePortalContainer}'s sync effect from re-running
515
+ * on every render.
516
+ */
517
+ function useThemeScope(
518
+ resolvedMode: ResolvedColorMode,
519
+ presetClass: string,
520
+ className: string | undefined,
521
+ ): ThemeScope {
522
+ return useMemo(
523
+ () => ({
524
+ className: cn("stgm", presetClass, className),
525
+ colorMode: resolvedMode,
526
+ }),
527
+ [resolvedMode, presetClass, className],
528
+ );
529
+ }
530
+
483
531
  /**
484
532
  * Creates and manages a portal container `<div>` appended to
485
- * `document.body` that mirrors the scoping attributes of the main
486
- * provider container.
533
+ * `document.body` that carries the identical {@link ThemeScope} as the
534
+ * main provider container.
487
535
  *
488
- * Portaled content (popovers, dialogs, menus) that targets this
489
- * element will inherit the correct `--stgm-*` token values —
490
- * including dark-mode overrides — because the container carries
491
- * `data-stgm-color-mode` and the preset class.
536
+ * Portaled content (popovers, dialogs, menus) that targets this element
537
+ * inherits the correct `--stgm-*` token values — including dark-mode
538
+ * overrides and any host `className` token overrides — because the
539
+ * container carries the same class and `data-stgm-color-mode` attribute
540
+ * as the in-tree scope. The node lives outside the provider subtree, so
541
+ * these must be set on the node directly rather than inherited via the
542
+ * DOM cascade.
492
543
  *
493
- * The element is created once on mount and removed on unmount.
494
- * Attribute values are kept in sync with prop changes via a
495
- * separate effect.
544
+ * The element is created once on mount and removed on unmount. Scope
545
+ * values are kept in sync with `scope` changes via a separate effect.
496
546
  *
497
547
  * Returns `null` during SSR (no `document`).
498
548
  */
499
- function usePortalContainer(
500
- colorMode: ResolvedColorMode,
501
- presetClass: string,
502
- ): HTMLElement | null {
549
+ function usePortalContainer(scope: ThemeScope): HTMLElement | null {
503
550
  const elRef = useRef<HTMLDivElement | null>(null);
504
551
  const [container, setContainer] = useState<HTMLElement | null>(null);
505
552
 
506
553
  useEffect(() => {
507
554
  const el = document.createElement("div");
508
- el.className = cn("stgm", presetClass);
509
- el.setAttribute("data-stgm-color-mode", colorMode);
555
+ el.className = scope.className;
556
+ el.setAttribute("data-stgm-color-mode", scope.colorMode);
510
557
  el.setAttribute("data-stgm-portal", "");
511
558
  document.body.appendChild(el);
512
559
  elRef.current = el;
@@ -517,15 +564,15 @@ function usePortalContainer(
517
564
  elRef.current = null;
518
565
  };
519
566
  // Intentionally empty deps: create once on mount, remove on unmount.
520
- // Attribute syncing is handled by the effect below.
567
+ // Scope syncing is handled by the effect below.
521
568
  }, []);
522
569
 
523
570
  useEffect(() => {
524
571
  const el = elRef.current;
525
572
  if (!el) return;
526
- el.className = cn("stgm", presetClass);
527
- el.setAttribute("data-stgm-color-mode", colorMode);
528
- }, [colorMode, presetClass]);
573
+ el.className = scope.className;
574
+ el.setAttribute("data-stgm-color-mode", scope.colorMode);
575
+ }, [scope]);
529
576
 
530
577
  return container;
531
578
  }
@@ -58,32 +58,32 @@ function openPreviewFor(name: string) {
58
58
 
59
59
  afterEach(cleanup);
60
60
 
61
- describe("ArtifactsTab — plan Implement wiring", () => {
62
- it("shows Implement in the preview of a plan.md artifact", () => {
61
+ describe("ArtifactsTab — plan 'Build from plan' wiring", () => {
62
+ it("shows 'Build from plan' in the preview of a plan.md artifact", () => {
63
63
  renderTab(vi.fn());
64
64
 
65
65
  openPreviewFor("plan.md");
66
66
 
67
67
  const dialog = document.querySelector("dialog")!;
68
- expect(within(dialog).getByText("Implement")).toBeTruthy();
68
+ expect(within(dialog).getByText("Build from plan")).toBeTruthy();
69
69
  });
70
70
 
71
- it("does not show Implement in the preview of a non-plan artifact", () => {
71
+ it("does not show 'Build from plan' in the preview of a non-plan artifact", () => {
72
72
  renderTab(vi.fn());
73
73
 
74
74
  openPreviewFor("notes.md");
75
75
 
76
76
  const dialog = document.querySelector("dialog")!;
77
- expect(within(dialog).queryByText("Implement")).toBeNull();
77
+ expect(within(dialog).queryByText("Build from plan")).toBeNull();
78
78
  });
79
79
 
80
- it("invokes onImplementPlan when Implement is clicked for a plan", () => {
80
+ it("invokes onImplementPlan when 'Build from plan' is clicked for a plan", () => {
81
81
  const onImplementPlan = vi.fn();
82
82
  renderTab(onImplementPlan);
83
83
 
84
84
  openPreviewFor("plan.md");
85
85
  const dialog = document.querySelector("dialog")!;
86
- fireEvent.click(within(dialog).getByText("Implement"));
86
+ fireEvent.click(within(dialog).getByText("Build from plan"));
87
87
 
88
88
  expect(onImplementPlan).toHaveBeenCalledTimes(1);
89
89
  });