@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.
- package/execution/ArtifactContentRenderer.d.ts.map +1 -1
- package/execution/ArtifactContentRenderer.js +7 -3
- package/execution/ArtifactContentRenderer.js.map +1 -1
- package/execution/ArtifactPreviewModal.d.ts +3 -3
- package/execution/ArtifactPreviewModal.js +4 -4
- package/execution/ArtifactPreviewModal.js.map +1 -1
- package/execution/MessageEntry.d.ts.map +1 -1
- package/execution/MessageEntry.js +7 -3
- package/execution/MessageEntry.js.map +1 -1
- package/execution/PlanArtifactCard.d.ts +25 -14
- package/execution/PlanArtifactCard.d.ts.map +1 -1
- package/execution/PlanArtifactCard.js +25 -10
- package/execution/PlanArtifactCard.js.map +1 -1
- package/execution/PlanCompletionCard.d.ts +12 -9
- package/execution/PlanCompletionCard.d.ts.map +1 -1
- package/execution/PlanCompletionCard.js +14 -9
- package/execution/PlanCompletionCard.js.map +1 -1
- package/execution/use-build-from-plan-hotkey.d.ts +19 -0
- package/execution/use-build-from-plan-hotkey.d.ts.map +1 -0
- package/execution/use-build-from-plan-hotkey.js +30 -0
- package/execution/use-build-from-plan-hotkey.js.map +1 -0
- package/internal/markdown-components.d.ts +18 -0
- package/internal/markdown-components.d.ts.map +1 -1
- package/internal/markdown-components.js +33 -0
- package/internal/markdown-components.js.map +1 -1
- package/package.json +4 -4
- package/portal-container.d.ts +9 -6
- package/portal-container.d.ts.map +1 -1
- package/portal-container.js +9 -6
- package/portal-container.js.map +1 -1
- package/provider.d.ts.map +1 -1
- package/provider.js +41 -19
- package/provider.js.map +1 -1
- package/src/__tests__/provider-theme-scope.test.tsx +94 -0
- package/src/execution/ArtifactContentRenderer.tsx +11 -2
- package/src/execution/ArtifactPreviewModal.tsx +7 -7
- package/src/execution/MessageEntry.tsx +14 -3
- package/src/execution/PlanArtifactCard.tsx +60 -51
- package/src/execution/PlanCompletionCard.tsx +20 -13
- package/src/execution/__tests__/ArtifactContentRenderer.test.tsx +47 -0
- package/src/execution/__tests__/ArtifactPreviewModal.test.tsx +6 -6
- package/src/execution/__tests__/MessageThread.test.tsx +3 -3
- package/src/execution/__tests__/PlanArtifactCard.test.tsx +120 -31
- package/src/execution/__tests__/PlanCompletionCard.test.tsx +31 -2
- package/src/execution/__tests__/message-entry.test.tsx +27 -0
- package/src/execution/use-build-from-plan-hotkey.ts +39 -0
- package/src/internal/__tests__/markdown-components.test.tsx +53 -0
- package/src/internal/markdown-components.tsx +36 -0
- package/src/portal-container.ts +9 -6
- package/src/provider.tsx +70 -23
- package/src/session/inspector/__tests__/ArtifactsTab.test.tsx +7 -7
- 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
|
*
|
package/src/portal-container.ts
CHANGED
|
@@ -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`
|
|
9
|
-
* the
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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 {
|
|
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
|
|
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={
|
|
192
|
-
data-stgm-color-mode={
|
|
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
|
|
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
|
-
*
|
|
490
|
-
*
|
|
491
|
-
* `data-stgm-color-mode`
|
|
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
|
-
*
|
|
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 =
|
|
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
|
-
//
|
|
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 =
|
|
527
|
-
el.setAttribute("data-stgm-color-mode", colorMode);
|
|
528
|
-
}, [
|
|
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
|
|
62
|
-
it("shows
|
|
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("
|
|
68
|
+
expect(within(dialog).getByText("Build from plan")).toBeTruthy();
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
it("does not show
|
|
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("
|
|
77
|
+
expect(within(dialog).queryByText("Build from plan")).toBeNull();
|
|
78
78
|
});
|
|
79
79
|
|
|
80
|
-
it("invokes onImplementPlan when
|
|
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("
|
|
86
|
+
fireEvent.click(within(dialog).getByText("Build from plan"));
|
|
87
87
|
|
|
88
88
|
expect(onImplementPlan).toHaveBeenCalledTimes(1);
|
|
89
89
|
});
|