@runtypelabs/persona 4.7.0 → 4.9.0

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 (185) hide show
  1. package/README.md +1 -0
  2. package/dist/animations/glyph-cycle.cjs +2 -2
  3. package/dist/animations/glyph-cycle.d.cts +1 -1
  4. package/dist/animations/glyph-cycle.d.ts +1 -1
  5. package/dist/animations/glyph-cycle.js +2 -2
  6. package/dist/animations/{types-CSmiKRVa.d.cts → types-4ROVJ1gA.d.cts} +85 -3
  7. package/dist/animations/{types-CSmiKRVa.d.ts → types-4ROVJ1gA.d.ts} +85 -3
  8. package/dist/animations/wipe.cjs +2 -2
  9. package/dist/animations/wipe.d.cts +1 -1
  10. package/dist/animations/wipe.d.ts +1 -1
  11. package/dist/chunk-5EIIHQLQ.js +1 -0
  12. package/dist/chunk-IO5VVUKP.js +3 -0
  13. package/dist/chunk-IPVK3KOM.js +1 -0
  14. package/dist/chunk-UPO4GUFC.js +1 -0
  15. package/dist/codegen.cjs +13 -13
  16. package/dist/codegen.js +14 -14
  17. package/dist/context-mentions-7S5KVUTG.js +169 -0
  18. package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
  19. package/dist/context-mentions-inline.cjs +4 -0
  20. package/dist/context-mentions-inline.d.cts +203 -0
  21. package/dist/context-mentions-inline.d.ts +203 -0
  22. package/dist/context-mentions-inline.js +4 -0
  23. package/dist/context-mentions.cjs +295 -0
  24. package/dist/context-mentions.d.cts +7025 -0
  25. package/dist/context-mentions.d.ts +7025 -0
  26. package/dist/context-mentions.js +295 -0
  27. package/dist/index.cjs +99 -68
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +1507 -182
  30. package/dist/index.d.ts +1507 -182
  31. package/dist/index.global.js +88 -56
  32. package/dist/index.global.js.map +1 -1
  33. package/dist/index.js +99 -68
  34. package/dist/index.js.map +1 -1
  35. package/dist/install.global.js +1 -1
  36. package/dist/install.global.js.map +1 -1
  37. package/dist/launcher.global.js +3 -2
  38. package/dist/launcher.global.js.map +1 -1
  39. package/dist/markdown-parsers.js +24 -24
  40. package/dist/plugin-kit.cjs +1 -1
  41. package/dist/plugin-kit.d.cts +17 -0
  42. package/dist/plugin-kit.d.ts +17 -0
  43. package/dist/plugin-kit.js +1 -1
  44. package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
  45. package/dist/runtype-tts.js +1 -1
  46. package/dist/session-reconnect-JKIJBHS5.js +1 -0
  47. package/dist/smart-dom-reader.cjs +18 -17
  48. package/dist/smart-dom-reader.d.cts +1260 -16
  49. package/dist/smart-dom-reader.d.ts +1260 -16
  50. package/dist/smart-dom-reader.js +18 -17
  51. package/dist/testing.cjs +3 -3
  52. package/dist/testing.js +3 -3
  53. package/dist/theme-editor-preview.cjs +260 -58
  54. package/dist/theme-editor-preview.d.cts +1234 -15
  55. package/dist/theme-editor-preview.d.ts +1234 -15
  56. package/dist/theme-editor-preview.js +87 -58
  57. package/dist/theme-editor.cjs +10 -10
  58. package/dist/theme-editor.d.cts +1226 -15
  59. package/dist/theme-editor.d.ts +1226 -15
  60. package/dist/theme-editor.js +10 -10
  61. package/dist/theme-reference.cjs +1 -1
  62. package/dist/theme-reference.d.cts +76 -0
  63. package/dist/theme-reference.d.ts +76 -0
  64. package/dist/theme-reference.js +1 -1
  65. package/dist/voice-worklet-player.cjs +2 -2
  66. package/dist/voice-worklet-player.js +2 -2
  67. package/dist/webmcp-polyfill.js +2 -2
  68. package/dist/widget.css +1 -1
  69. package/package.json +16 -5
  70. package/src/artifacts-session.test.ts +178 -0
  71. package/src/client.test.ts +255 -1
  72. package/src/client.ts +157 -60
  73. package/src/components/artifact-card.test.ts +333 -0
  74. package/src/components/artifact-card.ts +75 -28
  75. package/src/components/artifact-inline.test.ts +1328 -0
  76. package/src/components/artifact-inline.ts +920 -0
  77. package/src/components/artifact-pane.test.ts +1042 -0
  78. package/src/components/artifact-pane.ts +440 -131
  79. package/src/components/artifact-preview.test.ts +1155 -0
  80. package/src/components/artifact-preview.ts +994 -0
  81. package/src/components/composer-parts.ts +3 -12
  82. package/src/components/context-mention-button.test.ts +70 -0
  83. package/src/components/context-mention-button.ts +82 -0
  84. package/src/components/context-mention-chip.ts +134 -0
  85. package/src/components/context-mention-menu.test.ts +508 -0
  86. package/src/components/context-mention-menu.ts +0 -0
  87. package/src/components/message-bubble.test.ts +175 -0
  88. package/src/components/message-bubble.ts +177 -19
  89. package/src/components/panel.ts +7 -10
  90. package/src/components/pill-composer-builder.test.ts +6 -2
  91. package/src/components/pill-composer-builder.ts +6 -6
  92. package/src/components/reasoning-bubble.ts +1 -13
  93. package/src/components/registry.ts +38 -3
  94. package/src/components/tool-bubble.ts +1 -13
  95. package/src/context-mentions-bundle.test.ts +163 -0
  96. package/src/context-mentions-entry.ts +185 -0
  97. package/src/context-mentions-inline-entry.test.ts +136 -0
  98. package/src/context-mentions-inline-entry.ts +226 -0
  99. package/src/context-mentions-inline-loader.test.ts +30 -0
  100. package/src/context-mentions-inline-loader.ts +36 -0
  101. package/src/context-mentions-inline.ts +15 -0
  102. package/src/context-mentions-loader.ts +32 -0
  103. package/src/context-mentions.ts +16 -0
  104. package/src/defaults.ts +1 -0
  105. package/src/generated/runtype-openapi-contract.ts +55 -3
  106. package/src/index-core.ts +46 -1
  107. package/src/index-global.ts +51 -0
  108. package/src/index.ts +8 -0
  109. package/src/markdown-parsers-loader.test.ts +158 -0
  110. package/src/markdown-parsers-loader.ts +83 -27
  111. package/src/plugin-kit.test.ts +40 -0
  112. package/src/plugin-kit.ts +39 -5
  113. package/src/runtime/host-layout.test.ts +163 -0
  114. package/src/runtime/host-layout.ts +110 -7
  115. package/src/runtime/init.ts +18 -61
  116. package/src/session.mentions.test.ts +175 -0
  117. package/src/session.test.ts +52 -4
  118. package/src/session.ts +197 -27
  119. package/src/smart-dom-reader.test.ts +129 -2
  120. package/src/smart-dom-reader.ts +127 -1
  121. package/src/styles/context-mention-menu-css.ts +176 -0
  122. package/src/styles/widget.css +1002 -152
  123. package/src/theme-editor/preview.ts +2 -0
  124. package/src/theme-editor/sections.test.ts +26 -1
  125. package/src/theme-editor/sections.ts +13 -5
  126. package/src/theme-reference.ts +2 -2
  127. package/src/tool-call-display-defaults.test.ts +1 -0
  128. package/src/types/theme.ts +79 -0
  129. package/src/types.ts +1041 -17
  130. package/src/ui.artifact-pane-gating.test.ts +636 -0
  131. package/src/ui.component-directive.test.ts +104 -0
  132. package/src/ui.composer-bar.test.ts +60 -2
  133. package/src/ui.detached-panel.test.ts +1049 -0
  134. package/src/ui.mention-submit.test.ts +235 -0
  135. package/src/ui.tool-display.test.ts +51 -0
  136. package/src/ui.ts +998 -159
  137. package/src/utils/artifact-custom-actions.ts +128 -0
  138. package/src/utils/artifact-display.test.ts +42 -0
  139. package/src/utils/artifact-display.ts +84 -0
  140. package/src/utils/artifact-file.test.ts +116 -0
  141. package/src/utils/artifact-file.ts +117 -0
  142. package/src/utils/artifact-gate.test.ts +112 -5
  143. package/src/utils/artifact-gate.ts +39 -14
  144. package/src/utils/artifact-loading-status.ts +55 -0
  145. package/src/utils/artifact-status-label.ts +190 -0
  146. package/src/utils/buttons.ts +7 -1
  147. package/src/utils/chunk-loader.test.ts +97 -0
  148. package/src/utils/chunk-loader.ts +88 -0
  149. package/src/utils/code-highlight.test.ts +186 -0
  150. package/src/utils/code-highlight.ts +400 -0
  151. package/src/utils/composer-contenteditable.test.ts +507 -0
  152. package/src/utils/composer-contenteditable.ts +626 -0
  153. package/src/utils/composer-document.test.ts +280 -0
  154. package/src/utils/composer-document.ts +293 -0
  155. package/src/utils/composer-history.test.ts +35 -7
  156. package/src/utils/composer-history.ts +30 -20
  157. package/src/utils/composer-input.ts +159 -0
  158. package/src/utils/context-mention-controller.test.ts +1215 -0
  159. package/src/utils/context-mention-controller.ts +1186 -0
  160. package/src/utils/context-mention-manager.test.ts +422 -0
  161. package/src/utils/context-mention-manager.ts +410 -0
  162. package/src/utils/context-mention-orchestrator.test.ts +538 -0
  163. package/src/utils/context-mention-orchestrator.ts +348 -0
  164. package/src/utils/icons.ts +2 -0
  165. package/src/utils/live-region.test.ts +108 -0
  166. package/src/utils/live-region.ts +94 -0
  167. package/src/utils/mention-channels.ts +63 -0
  168. package/src/utils/mention-llm-format.test.ts +91 -0
  169. package/src/utils/mention-llm-format.ts +79 -0
  170. package/src/utils/mention-matcher.test.ts +86 -0
  171. package/src/utils/mention-matcher.ts +221 -0
  172. package/src/utils/mention-token.ts +72 -0
  173. package/src/utils/mention-trigger.test.ts +155 -0
  174. package/src/utils/mention-trigger.ts +156 -0
  175. package/src/utils/roving-tablist.test.ts +152 -0
  176. package/src/utils/roving-tablist.ts +111 -0
  177. package/src/utils/spinner.ts +45 -0
  178. package/src/utils/theme.test.ts +76 -4
  179. package/src/utils/theme.ts +7 -0
  180. package/src/utils/tokens.ts +118 -11
  181. package/src/utils/tool-loading-animation.test.ts +32 -0
  182. package/src/utils/tool-loading-animation.ts +24 -0
  183. package/dist/chunk-DFBSCFYN.js +0 -1
  184. package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
  185. package/dist/session-reconnect-U77QFUR7.js +0 -1
@@ -0,0 +1,128 @@
1
+ import type {
2
+ PersonaArtifactActionContext,
3
+ PersonaArtifactCustomAction,
4
+ PersonaArtifactRecord,
5
+ } from "../types";
6
+ import { createIconButton, createLabelButton } from "./buttons";
7
+ import { createElement } from "./dom";
8
+
9
+ /** Base class applied to every custom artifact action button. */
10
+ const CUSTOM_ACTION_CLASS = "persona-artifact-custom-action-btn";
11
+
12
+ /**
13
+ * Builds a toolbar/card button for a {@link PersonaArtifactCustomAction}.
14
+ *
15
+ * The result always carries `persona-artifact-custom-action-btn` after the
16
+ * button-system base classes. When `opts.documentChrome` is set, the button
17
+ * also picks up the document-toolbar styling used by the built-in controls.
18
+ *
19
+ * A string `icon` routes through the shared button helpers; a factory `icon`
20
+ * is hand-assembled to match the same class structure so author-owned SVGs
21
+ * slot in without duplicating the button system. A throwing factory falls back
22
+ * to a no-icon button rather than breaking the toolbar.
23
+ *
24
+ * `opts.onClick` (used by the toolbar path) is attached as a click listener;
25
+ * the card path omits it and wires clicks via event delegation instead.
26
+ */
27
+ export function buildArtifactActionButton(
28
+ action: PersonaArtifactCustomAction,
29
+ opts?: { documentChrome?: boolean; onClick?: () => void }
30
+ ): HTMLButtonElement {
31
+ const documentChrome = opts?.documentChrome ?? false;
32
+
33
+ let btn: HTMLButtonElement;
34
+
35
+ if (typeof action.icon === "function") {
36
+ // Hand-assemble to mirror createIconButton/createLabelButton, with the
37
+ // author-provided element in place of a Lucide icon.
38
+ if (action.showLabel) {
39
+ const className =
40
+ "persona-label-btn persona-label-btn--sm " +
41
+ CUSTOM_ACTION_CLASS +
42
+ (documentChrome ? " persona-artifact-doc-copy-btn" : "");
43
+ btn = createElement("button", className);
44
+ } else {
45
+ const className =
46
+ "persona-icon-btn " +
47
+ CUSTOM_ACTION_CLASS +
48
+ (documentChrome ? " persona-artifact-doc-icon-btn" : "");
49
+ btn = createElement("button", className);
50
+ }
51
+ btn.type = "button";
52
+ btn.setAttribute("aria-label", action.label);
53
+ btn.title = action.label;
54
+
55
+ try {
56
+ const el = action.icon();
57
+ if (el) {
58
+ btn.appendChild(el);
59
+ }
60
+ } catch {
61
+ /* a throwing factory must not break the toolbar; fall back to no icon */
62
+ }
63
+
64
+ if (action.showLabel) {
65
+ const span = createElement("span");
66
+ span.textContent = action.label;
67
+ btn.appendChild(span);
68
+ }
69
+ } else if (action.showLabel || !action.icon) {
70
+ // Visible label, or no icon at all: icon-less actions fall back to a text
71
+ // button so the control always has a visible affordance (an empty
72
+ // icon-only button would render as a bare chip and warn on every render).
73
+ btn = createLabelButton({
74
+ icon: action.icon,
75
+ label: action.label,
76
+ className:
77
+ CUSTOM_ACTION_CLASS + (documentChrome ? " persona-artifact-doc-copy-btn" : ""),
78
+ });
79
+ } else {
80
+ // Icon-only: icon is a registry name.
81
+ btn = createIconButton({
82
+ icon: action.icon,
83
+ label: action.label,
84
+ className:
85
+ CUSTOM_ACTION_CLASS + (documentChrome ? " persona-artifact-doc-icon-btn" : ""),
86
+ });
87
+ }
88
+
89
+ if (opts?.onClick) {
90
+ btn.addEventListener("click", opts.onClick);
91
+ }
92
+
93
+ return btn;
94
+ }
95
+
96
+ /**
97
+ * Derives an action context from a persisted artifact record, mirroring how the
98
+ * artifact pane discriminates record shapes. Returns `null` when no record is
99
+ * available so callers can skip evaluating visibility gates / handlers.
100
+ */
101
+ export function artifactRecordActionContext(
102
+ record: PersonaArtifactRecord | undefined
103
+ ): PersonaArtifactActionContext | null {
104
+ if (!record) {
105
+ return null;
106
+ }
107
+
108
+ const ctx: PersonaArtifactActionContext = {
109
+ artifactId: record.id,
110
+ title: record.title ?? "",
111
+ artifactType: record.artifactType,
112
+ };
113
+
114
+ if (record.artifactType === "markdown") {
115
+ ctx.markdown = record.markdown ?? "";
116
+ if (record.file) {
117
+ ctx.file = record.file;
118
+ }
119
+ } else {
120
+ ctx.jsonPayload = JSON.stringify(
121
+ { component: record.component, props: record.props },
122
+ null,
123
+ 2
124
+ );
125
+ }
126
+
127
+ return ctx;
128
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { resolveArtifactDisplayMode } from "./artifact-display";
3
+
4
+ describe("resolveArtifactDisplayMode", () => {
5
+ it("returns 'panel' when the feature is undefined", () => {
6
+ expect(resolveArtifactDisplayMode(undefined, "markdown")).toBe("panel");
7
+ });
8
+
9
+ it("returns 'panel' when display is unset", () => {
10
+ expect(resolveArtifactDisplayMode({ enabled: true }, "markdown")).toBe("panel");
11
+ });
12
+
13
+ it("returns the string form directly", () => {
14
+ expect(resolveArtifactDisplayMode({ display: "inline" }, "markdown")).toBe("inline");
15
+ expect(resolveArtifactDisplayMode({ display: "card" }, "component")).toBe("card");
16
+ });
17
+
18
+ it("uses the object default when no byType entry matches", () => {
19
+ expect(resolveArtifactDisplayMode({ display: { default: "card" } }, "markdown")).toBe("card");
20
+ });
21
+
22
+ it("prefers a byType override over the default", () => {
23
+ const feature = {
24
+ display: { default: "panel" as const, byType: { markdown: "inline" as const } }
25
+ };
26
+ expect(resolveArtifactDisplayMode(feature, "markdown")).toBe("inline");
27
+ });
28
+
29
+ it("falls back to the default on a byType miss", () => {
30
+ const feature = {
31
+ display: { default: "card" as const, byType: { markdown: "inline" as const } }
32
+ };
33
+ expect(resolveArtifactDisplayMode(feature, "component")).toBe("card");
34
+ });
35
+
36
+ it("returns 'panel' for an object with neither default nor a matching byType", () => {
37
+ expect(resolveArtifactDisplayMode({ display: {} }, "markdown")).toBe("panel");
38
+ expect(
39
+ resolveArtifactDisplayMode({ display: { byType: { component: "inline" } } }, "markdown")
40
+ ).toBe("panel");
41
+ });
42
+ });
@@ -0,0 +1,84 @@
1
+ import type {
2
+ AgentWidgetArtifactsFeature,
3
+ PersonaArtifactDisplayMode,
4
+ PersonaArtifactFileMeta
5
+ } from "../types";
6
+
7
+ /**
8
+ * Resolves the display mode for an artifact of the given type from
9
+ * `features.artifacts.display`.
10
+ *
11
+ * Resolution order: `display.byType[artifactType]`, else `display.default`,
12
+ * else `display` (string form), else `"panel"` (current behavior).
13
+ */
14
+ export function resolveArtifactDisplayMode(
15
+ feature: AgentWidgetArtifactsFeature | undefined,
16
+ artifactType: string
17
+ ): PersonaArtifactDisplayMode {
18
+ const display = feature?.display;
19
+ if (!display) return "panel";
20
+ if (typeof display === "string") return display;
21
+ const byType = display.byType as
22
+ | Partial<Record<string, PersonaArtifactDisplayMode>>
23
+ | undefined;
24
+ return byType?.[artifactType] ?? display.default ?? "panel";
25
+ }
26
+
27
+ /** Props embedded in an in-thread artifact block's rawContent JSON. */
28
+ export type ArtifactRefBlockProps = {
29
+ artifactId: string;
30
+ title?: string;
31
+ artifactType: string;
32
+ status: "streaming" | "complete";
33
+ /** File metadata for previewable file artifacts (persists the download path). */
34
+ file?: PersonaArtifactFileMeta;
35
+ /**
36
+ * Component name for component-type artifacts. Only embedded for the
37
+ * "inline" display mode: the inline block renders component artifacts
38
+ * through the registry, so it needs the name in its props; the card never
39
+ * reads it.
40
+ */
41
+ component?: string;
42
+ /**
43
+ * Props for component-type artifacts. Embedded alongside `component` for the
44
+ * "inline" display mode so a refreshed session re-invokes the registered
45
+ * renderer with its real props (the session artifact registry is not
46
+ * persisted); the card never reads them.
47
+ */
48
+ componentProps?: Record<string, unknown>;
49
+ /** Final markdown content, embedded once complete so hydration/download work after refresh. */
50
+ markdown?: string;
51
+ };
52
+
53
+ /**
54
+ * Builds the `rawContent` JSON for the synthetic assistant message that
55
+ * anchors an artifact in the transcript (message id `artifact-ref-<id>`).
56
+ *
57
+ * "card"/"panel" modes render the compact `PersonaArtifactCard`; "inline"
58
+ * renders the `PersonaArtifactInline` preview block. Both share this shape so
59
+ * transcript persistence and hydration work unchanged. Used by the streaming
60
+ * client (`artifact_start`) and the programmatic `session.upsertArtifact()`.
61
+ */
62
+ export function buildArtifactRefRawContent(
63
+ displayMode: PersonaArtifactDisplayMode,
64
+ props: ArtifactRefBlockProps
65
+ ): string {
66
+ return JSON.stringify({
67
+ component:
68
+ displayMode === "inline" ? "PersonaArtifactInline" : "PersonaArtifactCard",
69
+ props: {
70
+ artifactId: props.artifactId,
71
+ title: props.title,
72
+ artifactType: props.artifactType,
73
+ status: props.status,
74
+ ...(props.file ? { file: props.file } : {}),
75
+ ...(displayMode === "inline" && props.component
76
+ ? { component: props.component }
77
+ : {}),
78
+ ...(displayMode === "inline" && props.componentProps
79
+ ? { componentProps: props.componentProps }
80
+ : {}),
81
+ ...(props.markdown !== undefined ? { markdown: props.markdown } : {})
82
+ }
83
+ });
84
+ }
@@ -0,0 +1,116 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ extractFileSource,
4
+ fileKindOf,
5
+ fileTypeLabel,
6
+ downloadInfoFor,
7
+ basenameOf,
8
+ } from "./artifact-file";
9
+
10
+ // Canonical cross-repo fixture. The zero-width space is written as a
11
+ // backslash-u200b escape sequence (not a literal invisible char) so the bytes
12
+ // are unambiguous in source.
13
+ // Mirrors core/packages/shared/src/file-artifact.ts escapeFenceTerminators fixture — keep byte-identical.
14
+ const ZWSP = "\u200b";
15
+ const FIXTURE_RAW = "# Title\n\n```js\nconsole.log('hi')\n```\n";
16
+ const FIXTURE_ESCAPED =
17
+ "# Title\n\n`" + ZWSP + "``js\nconsole.log('hi')\n`" + ZWSP + "``\n";
18
+ const FIXTURE_WIRE = "```md\n" + FIXTURE_ESCAPED + "\n```";
19
+
20
+ describe("extractFileSource", () => {
21
+ it("round-trips the canonical cross-repo fixture byte-for-byte", () => {
22
+ expect(extractFileSource(FIXTURE_WIRE)).toBe(FIXTURE_RAW);
23
+ });
24
+
25
+ it("passes through content without a leading fence unchanged", () => {
26
+ const plain = "just some text\nwith lines\n";
27
+ expect(extractFileSource(plain)).toBe(plain);
28
+ });
29
+
30
+ it("reverses multiple escaped fences", () => {
31
+ const raw = "```a\n```b\n```c\n";
32
+ const escaped = raw.split("```").join("`" + ZWSP + "``");
33
+ const wire = "```md\n" + escaped + "\n```";
34
+ expect(extractFileSource(wire)).toBe(raw);
35
+ });
36
+
37
+ it("unfences a simple html file", () => {
38
+ const raw = "<h1>hi</h1>\n";
39
+ const wire = "```html\n" + raw + "\n```";
40
+ expect(extractFileSource(wire)).toBe(raw);
41
+ });
42
+
43
+ it("handles empty file content", () => {
44
+ const wire = "```md\n\n```";
45
+ expect(extractFileSource(wire)).toBe("");
46
+ });
47
+
48
+ it("returns empty string for non-string input", () => {
49
+ // @ts-expect-error deliberate misuse
50
+ expect(extractFileSource(undefined)).toBe("");
51
+ });
52
+ });
53
+
54
+ describe("fileKindOf", () => {
55
+ it("classifies by extension", () => {
56
+ expect(fileKindOf({ path: "a/cat.html", mimeType: "text/html" })).toBe("html");
57
+ expect(fileKindOf({ path: "cat.htm", mimeType: "text/html" })).toBe("html");
58
+ expect(fileKindOf({ path: "logo.svg", mimeType: "image/svg+xml" })).toBe("svg");
59
+ expect(fileKindOf({ path: "notes.md", mimeType: "text/markdown" })).toBe("markdown");
60
+ expect(fileKindOf({ path: "notes.mdx", mimeType: "text/markdown" })).toBe("markdown");
61
+ expect(fileKindOf({ path: "data.json", mimeType: "application/json" })).toBe("other");
62
+ });
63
+
64
+ it("falls back to mimeType when there is no extension", () => {
65
+ expect(fileKindOf({ path: "README", mimeType: "text/html" })).toBe("html");
66
+ expect(fileKindOf({ path: "drawing", mimeType: "image/svg+xml" })).toBe("svg");
67
+ expect(fileKindOf({ path: "doc", mimeType: "text/markdown" })).toBe("markdown");
68
+ expect(fileKindOf({ path: "blob", mimeType: "application/octet-stream" })).toBe("other");
69
+ });
70
+ });
71
+
72
+ describe("fileTypeLabel", () => {
73
+ it("produces human labels", () => {
74
+ expect(fileTypeLabel({ path: "cat.html", mimeType: "text/html" })).toBe("HTML");
75
+ expect(fileTypeLabel({ path: "logo.svg", mimeType: "image/svg+xml" })).toBe("SVG");
76
+ expect(fileTypeLabel({ path: "notes.md", mimeType: "text/markdown" })).toBe("Markdown");
77
+ expect(fileTypeLabel({ path: "data.csv", mimeType: "text/csv" })).toBe("CSV");
78
+ expect(fileTypeLabel({ path: "blob", mimeType: "application/octet-stream" })).toBe("File");
79
+ });
80
+ });
81
+
82
+ describe("basenameOf", () => {
83
+ it("returns the last path segment", () => {
84
+ expect(basenameOf("/mnt/session/outputs/cat.html")).toBe("cat.html");
85
+ expect(basenameOf("cat.html")).toBe("cat.html");
86
+ expect(basenameOf("a\\b\\c.txt")).toBe("c.txt");
87
+ });
88
+ });
89
+
90
+ describe("downloadInfoFor", () => {
91
+ it("uses basename + real MIME + unfenced source for file records", () => {
92
+ const info = downloadInfoFor({
93
+ title: "outputs/cat.html",
94
+ markdown: "```html\n<h1>hi</h1>\n\n```",
95
+ file: { path: "outputs/cat.html", mimeType: "text/html" },
96
+ });
97
+ expect(info.filename).toBe("cat.html");
98
+ expect(info.mime).toBe("text/html");
99
+ expect(info.content).toBe("<h1>hi</h1>\n");
100
+ });
101
+
102
+ it("preserves legacy .md / text/markdown behavior for non-file records", () => {
103
+ const info = downloadInfoFor({
104
+ title: "My Doc",
105
+ markdown: "## Hello\n\nbody",
106
+ });
107
+ expect(info.filename).toBe("My Doc.md");
108
+ expect(info.mime).toBe("text/markdown");
109
+ expect(info.content).toBe("## Hello\n\nbody");
110
+ });
111
+
112
+ it("defaults filename for a titleless non-file record", () => {
113
+ const info = downloadInfoFor({ markdown: "x" });
114
+ expect(info.filename).toBe("artifact.md");
115
+ });
116
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Helpers for previewable file artifacts.
3
+ *
4
+ * When a Claude Managed agent writes a file, the core backend encodes it as a
5
+ * markdown artifact whose content is a fenced code block (extension → fence
6
+ * language) plus a `file` metadata field on `artifact_start`. The content stays
7
+ * fenced on the wire for backward compatibility; these helpers recover the raw
8
+ * source and classify the file for preview / download.
9
+ */
10
+ import type { PersonaArtifactFileMeta } from "../types";
11
+
12
+ const ZERO_WIDTH_SPACE = "​";
13
+
14
+ /** basename of a POSIX or Windows style path. */
15
+ export function basenameOf(path: string): string {
16
+ const parts = String(path).split(/[\\/]/);
17
+ return parts[parts.length - 1] ?? "";
18
+ }
19
+
20
+ /** lowercase file extension (without the dot), or "" when there is none. */
21
+ function extensionOf(path: string): string {
22
+ const base = basenameOf(path);
23
+ const dot = base.lastIndexOf(".");
24
+ // dot at index 0 (dotfile) or no dot means "no extension"
25
+ if (dot <= 0) return "";
26
+ return base.slice(dot + 1).toLowerCase();
27
+ }
28
+
29
+ /**
30
+ * Recover raw file source from core's fenced wire content.
31
+ *
32
+ * The producer builds content as `` ```${lang}\n${escaped}\n``` `` where every
33
+ * literal triple-backtick in the source is replaced with backtick + U+200B
34
+ * (zero-width space) + backtick backtick. So the content is exactly
35
+ * `firstline + "\n" + escapedSource + "\n" + lastline`; dropping the first and
36
+ * last lines yields the escaped source, then the ZWSP escape is reversed.
37
+ *
38
+ * Graceful: if `markdown` does not start with a fence line, it is returned
39
+ * unchanged.
40
+ */
41
+ export function extractFileSource(markdown: string): string {
42
+ if (typeof markdown !== "string") return "";
43
+ const firstNl = markdown.indexOf("\n");
44
+ if (firstNl === -1) return markdown;
45
+ const firstLine = markdown.slice(0, firstNl);
46
+ if (!firstLine.startsWith("```")) return markdown;
47
+ const rest = markdown.slice(firstNl + 1);
48
+ const lastNl = rest.lastIndexOf("\n");
49
+ const escaped = lastNl === -1 ? rest : rest.slice(0, lastNl);
50
+ // Reverse core's fence-terminator escape (backtick + ZWSP + backtick backtick).
51
+ return escaped.split("`" + ZERO_WIDTH_SPACE + "``").join("```");
52
+ }
53
+
54
+ /** Coarse file kind used to pick a preview strategy. */
55
+ export function fileKindOf(
56
+ meta: PersonaArtifactFileMeta
57
+ ): "html" | "svg" | "markdown" | "other" {
58
+ const ext = extensionOf(meta.path);
59
+ if (ext) {
60
+ if (ext === "html" || ext === "htm") return "html";
61
+ if (ext === "svg") return "svg";
62
+ if (ext === "md" || ext === "mdx") return "markdown";
63
+ return "other";
64
+ }
65
+ const mime = (meta.mimeType || "").toLowerCase();
66
+ if (mime.includes("html")) return "html";
67
+ if (mime.includes("svg")) return "svg";
68
+ if (mime.includes("markdown")) return "markdown";
69
+ return "other";
70
+ }
71
+
72
+ /** Human-readable file type label for card subtitles / pane titles. */
73
+ export function fileTypeLabel(meta: PersonaArtifactFileMeta): string {
74
+ switch (fileKindOf(meta)) {
75
+ case "html":
76
+ return "HTML";
77
+ case "svg":
78
+ return "SVG";
79
+ case "markdown":
80
+ return "Markdown";
81
+ default: {
82
+ const ext = extensionOf(meta.path);
83
+ return ext ? ext.toUpperCase() : "File";
84
+ }
85
+ }
86
+ }
87
+
88
+ /** Minimal record shape needed to compute download info. */
89
+ export interface ArtifactDownloadSource {
90
+ title?: string;
91
+ markdown?: string;
92
+ file?: PersonaArtifactFileMeta;
93
+ }
94
+
95
+ /**
96
+ * Resolve the download filename, MIME type, and content for an artifact.
97
+ *
98
+ * File artifacts download the raw unfenced source under their basename with
99
+ * their real MIME type. Non-file markdown artifacts preserve the legacy
100
+ * behavior exactly: `<title>.md`, `text/markdown`, raw fenced/markdown content.
101
+ */
102
+ export function downloadInfoFor(record: ArtifactDownloadSource): {
103
+ filename: string;
104
+ mime: string;
105
+ content: string;
106
+ } {
107
+ const raw = record.markdown ?? "";
108
+ if (record.file) {
109
+ return {
110
+ filename: basenameOf(record.file.path) || "artifact",
111
+ mime: record.file.mimeType || "application/octet-stream",
112
+ content: extractFileSource(raw),
113
+ };
114
+ }
115
+ const title = record.title || "artifact";
116
+ return { filename: `${title}.md`, mime: "text/markdown", content: raw };
117
+ }
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { describe, expect, it, vi } from "vitest";
2
2
  import {
3
3
  applyArtifactLayoutCssVars,
4
4
  applyArtifactPaneAppearance,
@@ -69,10 +69,9 @@ describe("applyArtifactPaneAppearance", () => {
69
69
  );
70
70
  expect(mount.classList.contains("persona-artifact-appearance-panel")).toBe(true);
71
71
  expect(mount.style.getPropertyValue("--persona-artifact-pane-radius")).toBe("");
72
- expect(mount.classList.contains("persona-artifact-unified-split")).toBe(false);
73
72
  });
74
73
 
75
- it("adds unified split class and optional outer radius", () => {
74
+ it("treats unifiedSplitChrome as a no-op and no longer owns the welded outer radius", () => {
76
75
  const mount = createFakeMount();
77
76
  applyArtifactPaneAppearance(
78
77
  mount,
@@ -82,8 +81,11 @@ describe("applyArtifactPaneAppearance", () => {
82
81
  },
83
82
  })
84
83
  );
85
- expect(mount.classList.contains("persona-artifact-unified-split")).toBe(true);
86
- expect(mount.style.getPropertyValue("--persona-artifact-unified-outer-radius").trim()).toBe("12px");
84
+ // Welding is now the default (JS-toggled at runtime), so the deprecated
85
+ // option adds no class here. The welded outer radius is owned by ui.ts
86
+ // syncPanelChrome (derived from the resolved panel radius), not this function.
87
+ expect(mount.classList.contains("persona-artifact-unified-split")).toBe(false);
88
+ expect(mount.style.getPropertyValue("--persona-artifact-welded-outer-radius").trim()).toBe("");
87
89
  });
88
90
 
89
91
  it("adds seamless class and border radius on any mode", () => {
@@ -134,6 +136,79 @@ describe("applyArtifactPaneAppearance", () => {
134
136
  );
135
137
  expect(mount.classList.contains("persona-artifact-appearance-panel")).toBe(true);
136
138
  });
139
+
140
+ it("resolves detached when detachedPanel true and paneAppearance unset", () => {
141
+ const mount = createFakeMount();
142
+ applyArtifactPaneAppearance(
143
+ mount,
144
+ baseConfig({
145
+ launcher: { detachedPanel: true },
146
+ features: { artifacts: { enabled: true } },
147
+ })
148
+ );
149
+ expect(mount.classList.contains("persona-artifact-appearance-detached")).toBe(true);
150
+ expect(mount.classList.contains("persona-artifact-appearance-panel")).toBe(false);
151
+ });
152
+
153
+ it("explicit panel beats detachedPanel true", () => {
154
+ const mount = createFakeMount();
155
+ applyArtifactPaneAppearance(
156
+ mount,
157
+ baseConfig({
158
+ launcher: { detachedPanel: true },
159
+ features: { artifacts: { enabled: true, layout: { paneAppearance: "panel" } } },
160
+ })
161
+ );
162
+ expect(mount.classList.contains("persona-artifact-appearance-panel")).toBe(true);
163
+ expect(mount.classList.contains("persona-artifact-appearance-detached")).toBe(false);
164
+ });
165
+
166
+ it("explicit detached works without detachedPanel", () => {
167
+ const mount = createFakeMount();
168
+ applyArtifactPaneAppearance(
169
+ mount,
170
+ baseConfig({
171
+ features: { artifacts: { enabled: true, layout: { paneAppearance: "detached" } } },
172
+ })
173
+ );
174
+ expect(mount.classList.contains("persona-artifact-appearance-detached")).toBe(true);
175
+ });
176
+
177
+ it("treats unifiedSplitChrome as a silent no-op when detached", () => {
178
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
179
+ const mount = createFakeMount();
180
+ applyArtifactPaneAppearance(
181
+ mount,
182
+ baseConfig({
183
+ features: {
184
+ artifacts: {
185
+ enabled: true,
186
+ layout: { paneAppearance: "detached", unifiedSplitChrome: true },
187
+ },
188
+ },
189
+ })
190
+ );
191
+ expect(mount.classList.contains("persona-artifact-appearance-detached")).toBe(true);
192
+ expect(mount.classList.contains("persona-artifact-unified-split")).toBe(false);
193
+ // Deprecated no-op: no warning now that welding is the panel/seamless default.
194
+ expect(warn).not.toHaveBeenCalled();
195
+ warn.mockRestore();
196
+ });
197
+
198
+ it("keeps explicit paneShadow over detached defaults", () => {
199
+ const mount = createFakeMount();
200
+ applyArtifactPaneAppearance(
201
+ mount,
202
+ baseConfig({
203
+ launcher: { detachedPanel: true },
204
+ features: { artifacts: { enabled: true, layout: { paneShadow: "0 0 0 red" } } },
205
+ })
206
+ );
207
+ expect(mount.classList.contains("persona-artifact-appearance-detached")).toBe(true);
208
+ expect(mount.style.getPropertyValue("--persona-artifact-pane-shadow").trim()).toBe(
209
+ "0 0 0 red"
210
+ );
211
+ });
137
212
  });
138
213
 
139
214
  describe("applyArtifactPaneBorderTheme", () => {
@@ -213,6 +288,38 @@ describe("applyArtifactLayoutCssVars", () => {
213
288
  expect(mount.style.getPropertyValue("--persona-artifact-pane-padding").trim()).toBe("24px");
214
289
  });
215
290
 
291
+ it("defaults split gap to panel inset when detached, explicit splitGap wins", () => {
292
+ const detached = createFakeMount();
293
+ applyArtifactLayoutCssVars(
294
+ detached,
295
+ baseConfig({
296
+ launcher: { detachedPanel: true },
297
+ features: { artifacts: { enabled: true } },
298
+ })
299
+ );
300
+ expect(detached.style.getPropertyValue("--persona-artifact-split-gap").trim()).toBe(
301
+ "var(--persona-panel-inset)"
302
+ );
303
+
304
+ const explicit = createFakeMount();
305
+ applyArtifactLayoutCssVars(
306
+ explicit,
307
+ baseConfig({
308
+ launcher: { detachedPanel: true },
309
+ features: { artifacts: { enabled: true, layout: { splitGap: "2rem" } } },
310
+ })
311
+ );
312
+ expect(explicit.style.getPropertyValue("--persona-artifact-split-gap").trim()).toBe("2rem");
313
+
314
+ // Panel/seamless weld at gap 0 by default.
315
+ const welded = createFakeMount();
316
+ applyArtifactLayoutCssVars(
317
+ welded,
318
+ baseConfig({ features: { artifacts: { enabled: true } } })
319
+ );
320
+ expect(welded.style.getPropertyValue("--persona-artifact-split-gap").trim()).toBe("0");
321
+ });
322
+
216
323
  it("clears pane bg and padding when artifacts disabled", () => {
217
324
  const mount = createFakeMount();
218
325
  mount.style.setProperty("--persona-artifact-pane-bg", "#000");