@runtypelabs/persona 4.17.1 → 4.18.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.
- package/dist/chunk-3I2S3I5P.js +1 -0
- package/dist/chunk-76IELU7F.js +1 -0
- package/dist/chunk-EFJBUH5F.js +1 -0
- package/dist/chunk-PDPVORUF.js +1 -0
- package/dist/chunk-Q735PSZL.js +1 -0
- package/dist/chunk-VK6JOIYJ.js +1 -0
- package/dist/codegen.cjs +4 -4
- package/dist/codegen.js +4 -4
- package/dist/{context-mentions-BTSTIBFC.js → context-mentions-MJL6S6P2.js} +1 -1
- package/dist/context-mentions.cjs +9 -8
- package/dist/context-mentions.d.cts +1212 -14
- package/dist/context-mentions.d.ts +1212 -14
- package/dist/context-mentions.js +9 -8
- package/dist/event-stream-view-4EZ7BGFK.js +1 -0
- package/dist/event-stream-view.cjs +129 -0
- package/dist/event-stream-view.d.cts +8384 -0
- package/dist/event-stream-view.d.ts +8384 -0
- package/dist/event-stream-view.js +129 -0
- package/dist/history-view-BD6OXLNA.js +898 -0
- package/dist/history-view.cjs +898 -0
- package/dist/history-view.d.cts +1461 -0
- package/dist/history-view.d.ts +1461 -0
- package/dist/history-view.js +898 -0
- package/dist/index.cjs +77 -77
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1987 -17
- package/dist/index.d.ts +1987 -17
- package/dist/index.global.js +67 -66
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +77 -77
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +5 -4
- package/dist/launcher.global.js.map +1 -1
- package/dist/plugin-kit.cjs +1 -1
- package/dist/plugin-kit.d.cts +0 -8
- package/dist/plugin-kit.d.ts +0 -8
- package/dist/plugin-kit.js +1 -1
- package/dist/smart-dom-reader.d.cts +1212 -14
- package/dist/smart-dom-reader.d.ts +1212 -14
- package/dist/theme-editor-preview.cjs +970 -73
- package/dist/theme-editor-preview.d.cts +1331 -15
- package/dist/theme-editor-preview.d.ts +1331 -15
- package/dist/theme-editor-preview.js +81 -81
- package/dist/theme-editor.cjs +7 -7
- package/dist/theme-editor.d.cts +1215 -15
- package/dist/theme-editor.d.ts +1215 -15
- package/dist/theme-editor.js +9 -9
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +226 -7
- package/dist/theme-reference.d.ts +226 -7
- package/dist/theme-reference.js +1 -1
- package/dist/widget.css +1 -1
- package/package.json +16 -4
- package/src/client-history-rest.test.ts +1034 -0
- package/src/client-visitor-history.test.ts +596 -0
- package/src/client.ts +1040 -24
- package/src/components/artifact-inline.ts +8 -1
- package/src/components/artifact-pane.test.ts +278 -1
- package/src/components/artifact-pane.ts +135 -67
- package/src/components/artifact-preview.test.ts +14 -1
- package/src/components/artifact-preview.ts +9 -0
- package/src/components/header-builder.ts +3 -0
- package/src/components/header-layouts.test.ts +171 -7
- package/src/components/header-layouts.ts +83 -53
- package/src/components/header-parts.test.ts +154 -1
- package/src/components/header-parts.ts +266 -138
- package/src/components/history-confirm-dialog.ts +177 -0
- package/src/components/history-shell-copy.ts +99 -0
- package/src/components/history-view/announcer.ts +40 -0
- package/src/components/history-view/copy.ts +105 -0
- package/src/components/history-view/css.ts +1078 -0
- package/src/components/history-view/grouping.test.ts +142 -0
- package/src/components/history-view/grouping.ts +142 -0
- package/src/components/history-view/icons.ts +73 -0
- package/src/components/history-view/rows.ts +341 -0
- package/src/components/history-view/state.ts +309 -0
- package/src/components/history-view.test.ts +2660 -0
- package/src/components/history-view.ts +1891 -0
- package/src/components/launcher-surface.test.ts +133 -0
- package/src/components/launcher.test.ts +18 -0
- package/src/components/launcher.ts +7 -7
- package/src/components/panel.ts +17 -7
- package/src/components/welcome.ts +72 -2
- package/src/defaults.ts +36 -3
- package/src/event-stream-view-bundle.test.ts +69 -0
- package/src/event-stream-view-entry.ts +19 -0
- package/src/event-stream-view-loader.ts +31 -0
- package/src/event-stream-view.ts +19 -0
- package/src/generated/runtype-openapi-contract.ts +1 -0
- package/src/history-render.ts +325 -0
- package/src/history-view-bundle.test.ts +78 -0
- package/src/history-view-entry.ts +41 -0
- package/src/history-view-loader.test.ts +30 -0
- package/src/history-view-loader.ts +32 -0
- package/src/history-view.ts +28 -0
- package/src/index-core.ts +50 -2
- package/src/index-global.ts +55 -0
- package/src/internal/demo-history-provider.test.ts +402 -0
- package/src/internal/demo-history-provider.ts +689 -0
- package/src/internal/history-provider-registry.ts +25 -0
- package/src/internal/history-provider.ts +150 -0
- package/src/internal/runtype-history-provider.test.ts +313 -0
- package/src/internal/runtype-history-provider.ts +240 -0
- package/src/plugin-kit.test.ts +16 -0
- package/src/plugin-kit.ts +13 -5
- package/src/plugins/types.ts +177 -1
- package/src/session-history.test.ts +1397 -0
- package/src/session.ts +1200 -26
- package/src/styles/widget-styles.test.ts +133 -0
- package/src/styles/widget.css +227 -8
- package/src/theme-editor/preview-utils.test.ts +19 -0
- package/src/theme-editor/preview-utils.ts +15 -1
- package/src/theme-editor/preview.ts +7 -1
- package/src/theme-editor/role-mappings.ts +4 -1
- package/src/theme-editor/sections.test.ts +168 -1
- package/src/theme-editor/sections.ts +136 -7
- package/src/theme-editor/webmcp/summary.ts +16 -2
- package/src/theme-reference.ts +20 -6
- package/src/types/theme.ts +213 -7
- package/src/types.ts +927 -7
- package/src/ui.artifact-rail-resize.test.ts +235 -0
- package/src/ui.attachments-drop.test.ts +1 -0
- package/src/ui.header-update-stability.test.ts +2 -2
- package/src/ui.history-chunk.test.ts +72 -0
- package/src/ui.history-config-provider.test.ts +318 -0
- package/src/ui.history-controller.test.ts +711 -0
- package/src/ui.history-lifecycle.test.ts +456 -0
- package/src/ui.history-render-hooks.test.ts +904 -0
- package/src/ui.history-shell.test.ts +1887 -0
- package/src/ui.rail-overlay.test.ts +573 -0
- package/src/ui.rail-resize.test.ts +284 -0
- package/src/ui.rebuild-composer.test.ts +21 -0
- package/src/ui.render-welcome.test.ts +42 -0
- package/src/ui.shortcuts.test.ts +166 -0
- package/src/ui.ts +3071 -240
- package/src/utils/__fixtures__/client-conversation-history-wire.json +108 -0
- package/src/utils/artifact-file.ts +15 -0
- package/src/utils/artifact-gate.test.ts +4 -0
- package/src/utils/artifact-gate.ts +13 -17
- package/src/utils/artifact-resize.test.ts +28 -0
- package/src/utils/artifact-resize.ts +9 -2
- package/src/utils/buttons.ts +188 -1
- package/src/utils/config-merge.test.ts +91 -0
- package/src/utils/dropdown.ts +10 -1
- package/src/utils/history-messages.test.ts +284 -0
- package/src/utils/history-messages.ts +284 -0
- package/src/utils/icons.ts +3 -0
- package/src/utils/shortcuts.test.ts +288 -0
- package/src/utils/shortcuts.ts +221 -0
- package/src/utils/theme.test.ts +501 -4
- package/src/utils/theme.ts +42 -3
- package/src/utils/tokens.ts +153 -15
- package/src/utils/tooltip-timing.ts +4 -0
- package/src/utils/tooltip.test.ts +249 -7
- package/src/utils/tooltip.ts +146 -4
- package/src/utils/visitor-store.test.ts +425 -0
- package/src/utils/visitor-store.ts +337 -0
- package/dist/chunk-QJ4LV2KW.js +0 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_readme": "Generated by apps/api/tests/client-conversation-history-handlers.test.ts in runtypelabs/core. Do not hand-edit; regenerate with UPDATE_HISTORY_WIRE_FIXTURE=1. Pins the /v1/client/conversations wire shape a Persona message mapper must handle: divergent display vs model content, absent vs deliberately empty projections, withheld model-only content (displayAvailable false), media parts, and a terminal assistant turn finalized through the display-projections route. conversationRevision is normalized because it is a freshly minted opaque token.",
|
|
3
|
+
"list": [
|
|
4
|
+
{
|
|
5
|
+
"id": "record_fixture_0000000000",
|
|
6
|
+
"title": "record_fixture_0000000000",
|
|
7
|
+
"targetId": "flow_fixture_000000000000",
|
|
8
|
+
"flowId": "flow_fixture_000000000000",
|
|
9
|
+
"preview": "It is in transit and arrives Thursday.",
|
|
10
|
+
"messageCount": 9,
|
|
11
|
+
"createdAt": "2026-01-01T00:00:00.000Z",
|
|
12
|
+
"updatedAt": "2026-02-01T00:00:00.000Z"
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"detail": {
|
|
16
|
+
"id": "record_fixture_0000000000",
|
|
17
|
+
"title": "record_fixture_0000000000",
|
|
18
|
+
"targetId": "flow_fixture_000000000000",
|
|
19
|
+
"flowId": "flow_fixture_000000000000",
|
|
20
|
+
"preview": "It is in transit and arrives Thursday.",
|
|
21
|
+
"messageCount": 9,
|
|
22
|
+
"createdAt": "2026-01-01T00:00:00.000Z",
|
|
23
|
+
"updatedAt": "2026-02-01T00:00:00.000Z",
|
|
24
|
+
"conversationRevision": "<opaque>",
|
|
25
|
+
"messages": [
|
|
26
|
+
{
|
|
27
|
+
"id": "msg_string",
|
|
28
|
+
"role": "user",
|
|
29
|
+
"content": "Where is my order?",
|
|
30
|
+
"displayAvailable": true,
|
|
31
|
+
"timestamp": "2026-02-01T00:00:00.000Z"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"id": "msg_divergent",
|
|
35
|
+
"role": "assistant",
|
|
36
|
+
"content": "{\"directive\":\"order_card\",\"orderId\":\"A-1\"}",
|
|
37
|
+
"displayContent": "Order A-1 is on its way.",
|
|
38
|
+
"displayAvailable": true,
|
|
39
|
+
"timestamp": "2026-02-01T00:00:01.000Z"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"id": "msg_empty_projection",
|
|
43
|
+
"role": "assistant",
|
|
44
|
+
"content": "{\"directive\":\"open_modal\"}",
|
|
45
|
+
"displayContent": "",
|
|
46
|
+
"displayAvailable": true,
|
|
47
|
+
"timestamp": "2026-02-01T00:00:02.000Z"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"id": "msg_hidden_llm_content",
|
|
51
|
+
"role": "assistant",
|
|
52
|
+
"displayAvailable": false,
|
|
53
|
+
"timestamp": "2026-02-01T00:00:03.000Z"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"id": "msg_raw_directive",
|
|
57
|
+
"role": "assistant",
|
|
58
|
+
"displayAvailable": false,
|
|
59
|
+
"timestamp": "2026-02-01T00:00:04.000Z"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"id": "msg_image",
|
|
63
|
+
"role": "user",
|
|
64
|
+
"content": [
|
|
65
|
+
{
|
|
66
|
+
"text": "Looks like this:",
|
|
67
|
+
"type": "text"
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"type": "image",
|
|
71
|
+
"image": "data:image/png;base64,AAAA"
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
"displayAvailable": true,
|
|
75
|
+
"timestamp": "2026-02-01T00:00:05.000Z"
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"id": "msg_file",
|
|
79
|
+
"role": "user",
|
|
80
|
+
"content": [
|
|
81
|
+
{
|
|
82
|
+
"data": "ZmFrZQ==",
|
|
83
|
+
"type": "file",
|
|
84
|
+
"filename": "receipt.pdf",
|
|
85
|
+
"mimeType": "application/pdf"
|
|
86
|
+
}
|
|
87
|
+
],
|
|
88
|
+
"displayAvailable": true,
|
|
89
|
+
"timestamp": "2026-02-01T00:00:06.000Z"
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"id": "msg_absent_content",
|
|
93
|
+
"role": "assistant",
|
|
94
|
+
"displayAvailable": false,
|
|
95
|
+
"timestamp": "2026-02-01T00:00:07.000Z"
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"id": "msg_terminal_finalized",
|
|
99
|
+
"role": "assistant",
|
|
100
|
+
"content": "{\"tool\":\"track\",\"status\":\"in_transit\"}",
|
|
101
|
+
"displayContent": "It is in transit and arrives Thursday.",
|
|
102
|
+
"displayAvailable": true,
|
|
103
|
+
"timestamp": "2026-02-01T00:00:08.000Z"
|
|
104
|
+
}
|
|
105
|
+
],
|
|
106
|
+
"nextMessageCursor": null
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -115,3 +115,18 @@ export function downloadInfoFor(record: ArtifactDownloadSource): {
|
|
|
115
115
|
const title = record.title || "artifact";
|
|
116
116
|
return { filename: `${title}.md`, mime: "text/markdown", content: raw };
|
|
117
117
|
}
|
|
118
|
+
|
|
119
|
+
/** Save `content` as a browser download via a transient blob anchor. */
|
|
120
|
+
export function triggerArtifactDownload(info: {
|
|
121
|
+
filename: string;
|
|
122
|
+
mime: string;
|
|
123
|
+
content: string;
|
|
124
|
+
}): void {
|
|
125
|
+
const blob = new Blob([info.content], { type: info.mime });
|
|
126
|
+
const url = URL.createObjectURL(blob);
|
|
127
|
+
const a = document.createElement("a");
|
|
128
|
+
a.href = url;
|
|
129
|
+
a.download = info.filename;
|
|
130
|
+
a.click();
|
|
131
|
+
URL.revokeObjectURL(url);
|
|
132
|
+
}
|
|
@@ -371,6 +371,7 @@ describe("applyArtifactLayoutCssVars", () => {
|
|
|
371
371
|
documentToolbarIconColor: "#60a5fa",
|
|
372
372
|
documentToolbarToggleActiveBackground: "#262626",
|
|
373
373
|
documentToolbarToggleActiveBorderColor: "#444444",
|
|
374
|
+
documentToolbarToggleActiveColor: "#e5e5e5",
|
|
374
375
|
},
|
|
375
376
|
},
|
|
376
377
|
},
|
|
@@ -385,5 +386,8 @@ describe("applyArtifactLayoutCssVars", () => {
|
|
|
385
386
|
expect(mount.style.getPropertyValue("--persona-artifact-doc-toggle-active-border").trim()).toBe(
|
|
386
387
|
"#444444"
|
|
387
388
|
);
|
|
389
|
+
expect(mount.style.getPropertyValue("--persona-artifact-doc-toggle-active-color").trim()).toBe(
|
|
390
|
+
"#e5e5e5"
|
|
391
|
+
);
|
|
388
392
|
});
|
|
389
393
|
});
|
|
@@ -107,23 +107,19 @@ export function applyArtifactLayoutCssVars(mount: HTMLElement, config: AgentWidg
|
|
|
107
107
|
mount.style.removeProperty("--persona-artifact-pane-padding");
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (toggleBorder) {
|
|
124
|
-
mount.style.setProperty("--persona-artifact-doc-toggle-active-border", toggleBorder);
|
|
125
|
-
} else {
|
|
126
|
-
mount.style.removeProperty("--persona-artifact-doc-toggle-active-border");
|
|
110
|
+
const docToolbarVars: Array<[string | undefined, string]> = [
|
|
111
|
+
[l?.documentToolbarIconColor, "--persona-artifact-doc-toolbar-icon-color"],
|
|
112
|
+
[l?.documentToolbarToggleActiveBackground, "--persona-artifact-doc-toggle-active-bg"],
|
|
113
|
+
[l?.documentToolbarToggleActiveBorderColor, "--persona-artifact-doc-toggle-active-border"],
|
|
114
|
+
[l?.documentToolbarToggleActiveColor, "--persona-artifact-doc-toggle-active-color"],
|
|
115
|
+
];
|
|
116
|
+
for (const [raw, name] of docToolbarVars) {
|
|
117
|
+
const value = raw?.trim();
|
|
118
|
+
if (value) {
|
|
119
|
+
mount.style.setProperty(name, value);
|
|
120
|
+
} else {
|
|
121
|
+
mount.style.removeProperty(name);
|
|
122
|
+
}
|
|
127
123
|
}
|
|
128
124
|
|
|
129
125
|
applyArtifactPaneBorderTheme(mount, config);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
3
|
ARTIFACT_RESIZE_CHAT_MIN_PX,
|
|
4
|
+
ARTIFACT_RESIZE_RAILED_TRANSCRIPT_MIN_PX,
|
|
4
5
|
clampArtifactPaneWidth,
|
|
5
6
|
maxArtifactWidthFromSplit,
|
|
6
7
|
parseArtifactResizeMaxPxOptional,
|
|
@@ -61,4 +62,31 @@ describe("resolveArtifactPaneWidthPx", () => {
|
|
|
61
62
|
const w = resolveArtifactPaneWidthPx(900, 1200, 8, 6, "200px", "320px");
|
|
62
63
|
expect(w).toBeLessThanOrEqual(320);
|
|
63
64
|
});
|
|
65
|
+
|
|
66
|
+
it("defaults the chat minimum to the flat reserve", () => {
|
|
67
|
+
const explicit = resolveArtifactPaneWidthPx(
|
|
68
|
+
2000,
|
|
69
|
+
1200,
|
|
70
|
+
8,
|
|
71
|
+
6,
|
|
72
|
+
"200px",
|
|
73
|
+
undefined,
|
|
74
|
+
ARTIFACT_RESIZE_CHAT_MIN_PX
|
|
75
|
+
);
|
|
76
|
+
expect(resolveArtifactPaneWidthPx(2000, 1200, 8, 6, "200px")).toBe(explicit);
|
|
77
|
+
expect(explicit).toBe(1200 - ARTIFACT_RESIZE_CHAT_MIN_PX - 16 - 6);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("shrinks the max by a larger chat minimum (docked rail + transcript)", () => {
|
|
81
|
+
const railed = 260 + ARTIFACT_RESIZE_RAILED_TRANSCRIPT_MIN_PX;
|
|
82
|
+
const w = resolveArtifactPaneWidthPx(2000, 1200, 8, 6, "200px", undefined, railed);
|
|
83
|
+
expect(w).toBe(1200 - railed - 16 - 6);
|
|
84
|
+
expect(w).toBeLessThan(resolveArtifactPaneWidthPx(2000, 1200, 8, 6, "200px"));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("keeps min over max when the chat minimum eats the row", () => {
|
|
88
|
+
// 1200 - 1100 - gaps - handle < the 200px pane min: min still wins.
|
|
89
|
+
const w = resolveArtifactPaneWidthPx(2000, 1200, 8, 6, "200px", undefined, 1100);
|
|
90
|
+
expect(w).toBe(200);
|
|
91
|
+
});
|
|
64
92
|
});
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
/** Minimum width (px) reserved for the chat column while resizing the artifact pane. */
|
|
2
2
|
export const ARTIFACT_RESIZE_CHAT_MIN_PX = 200;
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Minimum transcript width (px) preserved beside a docked history rail while
|
|
6
|
+
* resizing the artifact pane. Added to the rail's own rendered width.
|
|
7
|
+
*/
|
|
8
|
+
export const ARTIFACT_RESIZE_RAILED_TRANSCRIPT_MIN_PX = 360;
|
|
9
|
+
|
|
4
10
|
/** Default minimum width (px) for the artifact column when `resizableMinWidth` is unset. */
|
|
5
11
|
export const ARTIFACT_RESIZE_PANE_MIN_DEFAULT_PX = 200;
|
|
6
12
|
|
|
@@ -54,10 +60,11 @@ export function resolveArtifactPaneWidthPx(
|
|
|
54
60
|
gapPx: number,
|
|
55
61
|
handleWidthPx: number,
|
|
56
62
|
resizableMinWidth?: string,
|
|
57
|
-
resizableMaxWidth?: string
|
|
63
|
+
resizableMaxWidth?: string,
|
|
64
|
+
chatMinPx: number = ARTIFACT_RESIZE_CHAT_MIN_PX
|
|
58
65
|
): number {
|
|
59
66
|
const minPx = parseArtifactResizePx(resizableMinWidth, ARTIFACT_RESIZE_PANE_MIN_DEFAULT_PX);
|
|
60
|
-
let maxPx = maxArtifactWidthFromSplit(splitWidthPx, gapPx, handleWidthPx,
|
|
67
|
+
let maxPx = maxArtifactWidthFromSplit(splitWidthPx, gapPx, handleWidthPx, chatMinPx);
|
|
61
68
|
maxPx = Math.max(minPx, maxPx);
|
|
62
69
|
const cap = parseArtifactResizeMaxPxOptional(resizableMaxWidth);
|
|
63
70
|
if (cap !== null) {
|
package/src/utils/buttons.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { createElement } from "./dom";
|
|
2
2
|
import { renderLucideIcon } from "./icons";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
createDropdownMenu,
|
|
5
|
+
type DropdownMenuHandle,
|
|
6
|
+
type DropdownMenuItem,
|
|
7
|
+
} from "./dropdown";
|
|
4
8
|
|
|
5
9
|
// ---------------------------------------------------------------------------
|
|
6
10
|
// createIconButton
|
|
@@ -240,6 +244,182 @@ export function createToggleGroup(options: CreateToggleGroupOptions): ToggleGrou
|
|
|
240
244
|
return { element: wrapper, setSelected };
|
|
241
245
|
}
|
|
242
246
|
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// createSplitButton
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
/** Options for {@link createSplitButton}. */
|
|
252
|
+
export interface CreateSplitButtonOptions {
|
|
253
|
+
/** Primary action text (always the accessible name; hidden visually with `iconOnly`). */
|
|
254
|
+
label: string;
|
|
255
|
+
/** Lucide icon for the primary half. */
|
|
256
|
+
icon?: string;
|
|
257
|
+
/** Primary icon size in pixels. Default: 14. */
|
|
258
|
+
iconSize?: number;
|
|
259
|
+
/** Render the primary half icon-only. */
|
|
260
|
+
iconOnly?: boolean;
|
|
261
|
+
/** Menu items behind the chevron half. */
|
|
262
|
+
menuItems: DropdownMenuItem[];
|
|
263
|
+
/** Called when a menu item is selected. */
|
|
264
|
+
onSelect: (id: string) => void;
|
|
265
|
+
/** Click handler for the primary half. */
|
|
266
|
+
onPrimary?: (e: MouseEvent) => void;
|
|
267
|
+
/** Menu alignment relative to the whole control. Default: "bottom-left". */
|
|
268
|
+
position?: "bottom-left" | "bottom-right";
|
|
269
|
+
/**
|
|
270
|
+
* Portal target for the menu (escapes `overflow: hidden` containers). A
|
|
271
|
+
* thunk is resolved when the menu is first built, so it may depend on the
|
|
272
|
+
* control being in the DOM.
|
|
273
|
+
*/
|
|
274
|
+
portal?: HTMLElement | (() => HTMLElement | null | undefined);
|
|
275
|
+
/** Accessible label of the chevron half. Default: "More options". */
|
|
276
|
+
chevronLabel?: string;
|
|
277
|
+
/** Chevron icon size in pixels. Default: 14. */
|
|
278
|
+
chevronIconSize?: number;
|
|
279
|
+
/** Extra CSS class(es) on the wrapper. */
|
|
280
|
+
className?: string;
|
|
281
|
+
/** Extra CSS class(es) on the primary button. */
|
|
282
|
+
primaryClassName?: string;
|
|
283
|
+
/** Extra CSS class(es) on the chevron button. */
|
|
284
|
+
chevronClassName?: string;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Handle returned by {@link createSplitButton}. */
|
|
288
|
+
export interface SplitButtonHandle {
|
|
289
|
+
/** The bordered wrapper holding both halves. */
|
|
290
|
+
element: HTMLElement;
|
|
291
|
+
/** The primary-action half. */
|
|
292
|
+
primaryButton: HTMLButtonElement;
|
|
293
|
+
/** The menu-trigger half. */
|
|
294
|
+
chevronButton: HTMLButtonElement;
|
|
295
|
+
/** Open the menu. */
|
|
296
|
+
openMenu: () => void;
|
|
297
|
+
/** Close the menu. */
|
|
298
|
+
closeMenu: () => void;
|
|
299
|
+
/** Toggle the menu. */
|
|
300
|
+
toggleMenu: () => void;
|
|
301
|
+
/** Remove from DOM and clean up. */
|
|
302
|
+
destroy: () => void;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Creates a split button: one bordered control holding two separately
|
|
307
|
+
* invocable halves — a primary action button and an icon-only chevron that
|
|
308
|
+
* opens a dropdown menu — divided by an internal hairline. This is the
|
|
309
|
+
* Primer/Fluent/Material split-button anatomy; for a single button whose whole
|
|
310
|
+
* surface opens the menu, use {@link createComboButton} instead.
|
|
311
|
+
*
|
|
312
|
+
* Both halves are real `button`s (two tab stops). The chevron carries its own
|
|
313
|
+
* accessible label plus `aria-haspopup`/`aria-expanded`, kept accurate across
|
|
314
|
+
* outside-click dismissal.
|
|
315
|
+
*
|
|
316
|
+
* ```ts
|
|
317
|
+
* import { createSplitButton } from "@runtypelabs/persona";
|
|
318
|
+
*
|
|
319
|
+
* const split = createSplitButton({
|
|
320
|
+
* label: "Copy",
|
|
321
|
+
* icon: "copy",
|
|
322
|
+
* menuItems: [{ id: "download", label: "Download" }],
|
|
323
|
+
* onPrimary: () => copyToClipboard(),
|
|
324
|
+
* onSelect: (id) => handleMenuAction(id),
|
|
325
|
+
* });
|
|
326
|
+
* toolbar.appendChild(split.element);
|
|
327
|
+
* ```
|
|
328
|
+
*/
|
|
329
|
+
export function createSplitButton(options: CreateSplitButtonOptions): SplitButtonHandle {
|
|
330
|
+
const {
|
|
331
|
+
label,
|
|
332
|
+
icon,
|
|
333
|
+
iconSize,
|
|
334
|
+
iconOnly,
|
|
335
|
+
menuItems,
|
|
336
|
+
onSelect,
|
|
337
|
+
onPrimary,
|
|
338
|
+
position = "bottom-left",
|
|
339
|
+
portal,
|
|
340
|
+
chevronLabel = "More options",
|
|
341
|
+
chevronIconSize,
|
|
342
|
+
className,
|
|
343
|
+
primaryClassName,
|
|
344
|
+
chevronClassName,
|
|
345
|
+
} = options;
|
|
346
|
+
|
|
347
|
+
const wrapper = createElement(
|
|
348
|
+
"div",
|
|
349
|
+
"persona-split-btn" + (className ? " " + className : ""),
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
const primaryBase = {
|
|
353
|
+
label,
|
|
354
|
+
className:
|
|
355
|
+
"persona-split-btn-primary" + (primaryClassName ? " " + primaryClassName : ""),
|
|
356
|
+
onClick: onPrimary,
|
|
357
|
+
};
|
|
358
|
+
const primaryButton =
|
|
359
|
+
iconOnly && icon
|
|
360
|
+
? createIconButton({ ...primaryBase, icon, size: iconSize ?? 14 })
|
|
361
|
+
: createLabelButton({ ...primaryBase, icon, iconSize: iconSize ?? 14 });
|
|
362
|
+
|
|
363
|
+
const chevronButton = createIconButton({
|
|
364
|
+
icon: "chevron-down",
|
|
365
|
+
label: chevronLabel,
|
|
366
|
+
size: chevronIconSize ?? 14,
|
|
367
|
+
className:
|
|
368
|
+
"persona-split-btn-chevron" + (chevronClassName ? " " + chevronClassName : ""),
|
|
369
|
+
aria: { "aria-haspopup": "true", "aria-expanded": "false" },
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
wrapper.append(primaryButton, chevronButton);
|
|
373
|
+
|
|
374
|
+
let dropdown: DropdownMenuHandle | null = null;
|
|
375
|
+
const ensureMenu = (): DropdownMenuHandle => {
|
|
376
|
+
if (dropdown) return dropdown;
|
|
377
|
+
const target = (typeof portal === "function" ? portal() : portal) ?? undefined;
|
|
378
|
+
dropdown = createDropdownMenu({
|
|
379
|
+
items: menuItems,
|
|
380
|
+
onSelect,
|
|
381
|
+
anchor: wrapper,
|
|
382
|
+
position,
|
|
383
|
+
portal: target,
|
|
384
|
+
onOpenChange: (open) => {
|
|
385
|
+
chevronButton.setAttribute("aria-expanded", open ? "true" : "false");
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
// Non-portaled menus position absolutely inside the anchor.
|
|
389
|
+
if (!target) wrapper.appendChild(dropdown.element);
|
|
390
|
+
return dropdown;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
// Build the (hidden) menu eagerly so it is queryable before first open, but
|
|
394
|
+
// wait a frame when the control is still detached: a portal thunk may need
|
|
395
|
+
// the mounted DOM to resolve its target.
|
|
396
|
+
if (wrapper.isConnected) {
|
|
397
|
+
ensureMenu();
|
|
398
|
+
} else {
|
|
399
|
+
requestAnimationFrame(() => {
|
|
400
|
+
if (!dropdown) ensureMenu();
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
chevronButton.addEventListener("click", (e) => {
|
|
405
|
+
e.stopPropagation();
|
|
406
|
+
ensureMenu().toggle();
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
element: wrapper,
|
|
411
|
+
primaryButton,
|
|
412
|
+
chevronButton,
|
|
413
|
+
openMenu: () => ensureMenu().show(),
|
|
414
|
+
closeMenu: () => dropdown?.hide(),
|
|
415
|
+
toggleMenu: () => ensureMenu().toggle(),
|
|
416
|
+
destroy: () => {
|
|
417
|
+
dropdown?.destroy();
|
|
418
|
+
wrapper.remove();
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
243
423
|
// ---------------------------------------------------------------------------
|
|
244
424
|
// createComboButton
|
|
245
425
|
// ---------------------------------------------------------------------------
|
|
@@ -380,9 +560,16 @@ export function createComboButton(options: CreateComboButtonOptions): ComboButto
|
|
|
380
560
|
wrapper.appendChild(dropdown.element);
|
|
381
561
|
}
|
|
382
562
|
|
|
563
|
+
// A shell may lock the menu while it has nothing to act on (e.g. no active
|
|
564
|
+
// conversation behind a conversation-title menu); locked, the combo reads
|
|
565
|
+
// as a plain title.
|
|
566
|
+
const locked = (): boolean =>
|
|
567
|
+
wrapper.getAttribute("data-persona-menu-locked") === "true";
|
|
568
|
+
|
|
383
569
|
// Click toggles dropdown
|
|
384
570
|
wrapper.addEventListener("click", (e) => {
|
|
385
571
|
e.stopPropagation();
|
|
572
|
+
if (locked()) return;
|
|
386
573
|
const isOpen = !dropdown.element.classList.contains("persona-hidden");
|
|
387
574
|
wrapper.setAttribute("aria-expanded", isOpen ? "false" : "true");
|
|
388
575
|
dropdown.toggle();
|
|
@@ -64,6 +64,97 @@ describe("mergeConfigUpdate", () => {
|
|
|
64
64
|
expect(next.onSessionInit).toBe(second);
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
+
it("replaces history identity/persistence callbacks wholesale", () => {
|
|
68
|
+
const firstProof = vi.fn();
|
|
69
|
+
const secondProof = vi.fn();
|
|
70
|
+
const firstGet = vi.fn();
|
|
71
|
+
const secondGet = vi.fn();
|
|
72
|
+
const firstSet = vi.fn();
|
|
73
|
+
const secondSet = vi.fn();
|
|
74
|
+
const firstClear = vi.fn();
|
|
75
|
+
const secondClear = vi.fn();
|
|
76
|
+
const firstClearSession = vi.fn();
|
|
77
|
+
const secondClearSession = vi.fn();
|
|
78
|
+
|
|
79
|
+
const prev = base({
|
|
80
|
+
getIdentityProof: firstProof,
|
|
81
|
+
getStoredConversationId: firstGet,
|
|
82
|
+
setStoredConversationId: firstSet,
|
|
83
|
+
clearStoredConversationId: firstClear,
|
|
84
|
+
clearStoredSessionId: firstClearSession,
|
|
85
|
+
});
|
|
86
|
+
const next = mergeConfigUpdate(prev, {
|
|
87
|
+
getIdentityProof: secondProof,
|
|
88
|
+
getStoredConversationId: secondGet,
|
|
89
|
+
setStoredConversationId: secondSet,
|
|
90
|
+
clearStoredConversationId: secondClear,
|
|
91
|
+
clearStoredSessionId: secondClearSession,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(next.getIdentityProof).toBe(secondProof);
|
|
95
|
+
expect(next.getStoredConversationId).toBe(secondGet);
|
|
96
|
+
expect(next.setStoredConversationId).toBe(secondSet);
|
|
97
|
+
expect(next.clearStoredConversationId).toBe(secondClear);
|
|
98
|
+
expect(next.clearStoredSessionId).toBe(secondClearSession);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("preserves history identity/persistence callbacks absent from the patch", () => {
|
|
102
|
+
const getProof = vi.fn();
|
|
103
|
+
const getId = vi.fn();
|
|
104
|
+
const setId = vi.fn();
|
|
105
|
+
const clearId = vi.fn();
|
|
106
|
+
const clearSession = vi.fn();
|
|
107
|
+
|
|
108
|
+
const prev = base({
|
|
109
|
+
getIdentityProof: getProof,
|
|
110
|
+
getStoredConversationId: getId,
|
|
111
|
+
setStoredConversationId: setId,
|
|
112
|
+
clearStoredConversationId: clearId,
|
|
113
|
+
clearStoredSessionId: clearSession,
|
|
114
|
+
});
|
|
115
|
+
const next = mergeConfigUpdate(prev, { launcher: { title: "New" } });
|
|
116
|
+
|
|
117
|
+
expect(next.getIdentityProof).toBe(getProof);
|
|
118
|
+
expect(next.getStoredConversationId).toBe(getId);
|
|
119
|
+
expect(next.setStoredConversationId).toBe(setId);
|
|
120
|
+
expect(next.clearStoredConversationId).toBe(clearId);
|
|
121
|
+
expect(next.clearStoredSessionId).toBe(clearSession);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("keeps features.history defaults when the host sets only enabled", () => {
|
|
125
|
+
const prev = base();
|
|
126
|
+
expect(prev.features?.history).toMatchObject({
|
|
127
|
+
enabled: false,
|
|
128
|
+
presentation: "panel",
|
|
129
|
+
showScopeStatus: true,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Same guarantee through the defaults merge itself.
|
|
133
|
+
const withDefaults = mergeWithDefaults({ features: { history: { enabled: true } } });
|
|
134
|
+
expect(withDefaults.features?.history).toMatchObject({
|
|
135
|
+
enabled: true,
|
|
136
|
+
presentation: "panel",
|
|
137
|
+
showScopeStatus: true,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const next = mergeConfigUpdate(prev, { features: { history: { enabled: true } } });
|
|
141
|
+
expect(next.features?.history?.enabled).toBe(true);
|
|
142
|
+
expect(next.features?.history?.presentation).toBe("panel");
|
|
143
|
+
expect(next.features?.history?.showScopeStatus).toBe(true);
|
|
144
|
+
// Sibling feature blocks survive the history patch.
|
|
145
|
+
expect(next.features?.askUserQuestion?.enabled).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("merges features.history.copy without dropping sibling copy overrides", () => {
|
|
149
|
+
const prev = base({ features: { history: { copy: { viewTitle: "Threads" } } } });
|
|
150
|
+
const next = mergeConfigUpdate(prev, {
|
|
151
|
+
features: { history: { copy: { emptyTitle: "Nothing yet" } } },
|
|
152
|
+
});
|
|
153
|
+
expect(next.features?.history?.copy?.viewTitle).toBe("Threads");
|
|
154
|
+
expect(next.features?.history?.copy?.emptyTitle).toBe("Nothing yet");
|
|
155
|
+
expect(next.features?.history?.presentation).toBe("panel");
|
|
156
|
+
});
|
|
157
|
+
|
|
67
158
|
it("guards boolean|object unions in both directions", () => {
|
|
68
159
|
const objFirst = base({ approval: { backgroundColor: "#ffffff" } });
|
|
69
160
|
const toScalar = mergeConfigUpdate(objFirst, { approval: false });
|
package/src/utils/dropdown.ts
CHANGED
|
@@ -28,6 +28,11 @@ export interface CreateDropdownOptions {
|
|
|
28
28
|
* Use this to escape `overflow: hidden` containers.
|
|
29
29
|
*/
|
|
30
30
|
portal?: HTMLElement;
|
|
31
|
+
/**
|
|
32
|
+
* Fired on every open/close transition, including outside-click dismissal.
|
|
33
|
+
* Use to keep a trigger's `aria-expanded` accurate.
|
|
34
|
+
*/
|
|
35
|
+
onOpenChange?: (open: boolean) => void;
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
export interface DropdownMenuHandle {
|
|
@@ -65,7 +70,7 @@ export interface DropdownMenuHandle {
|
|
|
65
70
|
* ```
|
|
66
71
|
*/
|
|
67
72
|
export function createDropdownMenu(options: CreateDropdownOptions): DropdownMenuHandle {
|
|
68
|
-
const { items, onSelect, anchor, position = 'bottom-left', portal } = options;
|
|
73
|
+
const { items, onSelect, anchor, position = 'bottom-left', portal, onOpenChange } = options;
|
|
69
74
|
|
|
70
75
|
const menu = createElement("div", "persona-dropdown-menu persona-hidden");
|
|
71
76
|
menu.setAttribute("role", "menu");
|
|
@@ -137,8 +142,10 @@ export function createDropdownMenu(options: CreateDropdownOptions): DropdownMenu
|
|
|
137
142
|
}
|
|
138
143
|
|
|
139
144
|
function show() {
|
|
145
|
+
const wasHidden = menu.classList.contains("persona-hidden");
|
|
140
146
|
reposition();
|
|
141
147
|
menu.classList.remove("persona-hidden");
|
|
148
|
+
if (wasHidden) onOpenChange?.(true);
|
|
142
149
|
// Defer click-outside listener to avoid catching the triggering click
|
|
143
150
|
requestAnimationFrame(() => {
|
|
144
151
|
const handler = (e: MouseEvent) => {
|
|
@@ -152,9 +159,11 @@ export function createDropdownMenu(options: CreateDropdownOptions): DropdownMenu
|
|
|
152
159
|
}
|
|
153
160
|
|
|
154
161
|
function hide() {
|
|
162
|
+
const wasOpen = !menu.classList.contains("persona-hidden");
|
|
155
163
|
menu.classList.add("persona-hidden");
|
|
156
164
|
cleanupClickOutside?.();
|
|
157
165
|
cleanupClickOutside = null;
|
|
166
|
+
if (wasOpen) onOpenChange?.(false);
|
|
158
167
|
}
|
|
159
168
|
|
|
160
169
|
function toggle() {
|