@runtypelabs/persona 4.17.0 → 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.
Files changed (165) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-BpWGm7_6.d.cts → types-DsmTD8TO.d.cts} +12 -0
  4. package/dist/animations/{types-BpWGm7_6.d.ts → types-DsmTD8TO.d.ts} +12 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/chunk-3I2S3I5P.js +1 -0
  8. package/dist/chunk-76IELU7F.js +1 -0
  9. package/dist/chunk-EFJBUH5F.js +1 -0
  10. package/dist/chunk-PDPVORUF.js +1 -0
  11. package/dist/chunk-Q735PSZL.js +1 -0
  12. package/dist/chunk-VK6JOIYJ.js +1 -0
  13. package/dist/codegen.cjs +4 -4
  14. package/dist/codegen.js +4 -4
  15. package/dist/{context-mentions-BTSTIBFC.js → context-mentions-MJL6S6P2.js} +1 -1
  16. package/dist/context-mentions.cjs +9 -8
  17. package/dist/context-mentions.d.cts +1224 -14
  18. package/dist/context-mentions.d.ts +1224 -14
  19. package/dist/context-mentions.js +9 -8
  20. package/dist/event-stream-view-4EZ7BGFK.js +1 -0
  21. package/dist/event-stream-view.cjs +129 -0
  22. package/dist/event-stream-view.d.cts +8384 -0
  23. package/dist/event-stream-view.d.ts +8384 -0
  24. package/dist/event-stream-view.js +129 -0
  25. package/dist/history-view-BD6OXLNA.js +898 -0
  26. package/dist/history-view.cjs +898 -0
  27. package/dist/history-view.d.cts +1461 -0
  28. package/dist/history-view.d.ts +1461 -0
  29. package/dist/history-view.js +898 -0
  30. package/dist/index.cjs +77 -77
  31. package/dist/index.cjs.map +1 -1
  32. package/dist/index.d.cts +2040 -21
  33. package/dist/index.d.ts +2040 -21
  34. package/dist/index.global.js +67 -66
  35. package/dist/index.global.js.map +1 -1
  36. package/dist/index.js +77 -77
  37. package/dist/index.js.map +1 -1
  38. package/dist/launcher.global.js +5 -4
  39. package/dist/launcher.global.js.map +1 -1
  40. package/dist/plugin-kit.cjs +1 -1
  41. package/dist/plugin-kit.d.cts +0 -8
  42. package/dist/plugin-kit.d.ts +0 -8
  43. package/dist/plugin-kit.js +1 -1
  44. package/dist/smart-dom-reader.d.cts +1224 -14
  45. package/dist/smart-dom-reader.d.ts +1224 -14
  46. package/dist/theme-editor-preview.cjs +970 -73
  47. package/dist/theme-editor-preview.d.cts +1343 -15
  48. package/dist/theme-editor-preview.d.ts +1343 -15
  49. package/dist/theme-editor-preview.js +81 -81
  50. package/dist/theme-editor.cjs +7 -7
  51. package/dist/theme-editor.d.cts +1227 -15
  52. package/dist/theme-editor.d.ts +1227 -15
  53. package/dist/theme-editor.js +9 -9
  54. package/dist/theme-reference.cjs +1 -1
  55. package/dist/theme-reference.d.cts +226 -7
  56. package/dist/theme-reference.d.ts +226 -7
  57. package/dist/theme-reference.js +1 -1
  58. package/dist/webmcp-polyfill.js +2 -2
  59. package/dist/widget.css +1 -1
  60. package/package.json +18 -6
  61. package/src/client-history-rest.test.ts +1034 -0
  62. package/src/client-visitor-history.test.ts +596 -0
  63. package/src/client.ts +1040 -24
  64. package/src/components/artifact-inline.ts +8 -1
  65. package/src/components/artifact-pane.test.ts +278 -1
  66. package/src/components/artifact-pane.ts +135 -67
  67. package/src/components/artifact-preview.test.ts +14 -1
  68. package/src/components/artifact-preview.ts +9 -0
  69. package/src/components/header-builder.ts +3 -0
  70. package/src/components/header-layouts.test.ts +171 -7
  71. package/src/components/header-layouts.ts +83 -53
  72. package/src/components/header-parts.test.ts +154 -1
  73. package/src/components/header-parts.ts +266 -138
  74. package/src/components/history-confirm-dialog.ts +177 -0
  75. package/src/components/history-shell-copy.ts +99 -0
  76. package/src/components/history-view/announcer.ts +40 -0
  77. package/src/components/history-view/copy.ts +105 -0
  78. package/src/components/history-view/css.ts +1078 -0
  79. package/src/components/history-view/grouping.test.ts +142 -0
  80. package/src/components/history-view/grouping.ts +142 -0
  81. package/src/components/history-view/icons.ts +73 -0
  82. package/src/components/history-view/rows.ts +341 -0
  83. package/src/components/history-view/state.ts +309 -0
  84. package/src/components/history-view.test.ts +2660 -0
  85. package/src/components/history-view.ts +1891 -0
  86. package/src/components/launcher-surface.test.ts +133 -0
  87. package/src/components/launcher.test.ts +18 -0
  88. package/src/components/launcher.ts +7 -7
  89. package/src/components/panel.ts +17 -7
  90. package/src/components/welcome.ts +72 -2
  91. package/src/defaults.ts +36 -3
  92. package/src/event-stream-view-bundle.test.ts +69 -0
  93. package/src/event-stream-view-entry.ts +19 -0
  94. package/src/event-stream-view-loader.ts +31 -0
  95. package/src/event-stream-view.ts +19 -0
  96. package/src/generated/runtype-openapi-contract.ts +54 -4
  97. package/src/history-render.ts +325 -0
  98. package/src/history-view-bundle.test.ts +78 -0
  99. package/src/history-view-entry.ts +41 -0
  100. package/src/history-view-loader.test.ts +30 -0
  101. package/src/history-view-loader.ts +32 -0
  102. package/src/history-view.ts +28 -0
  103. package/src/index-core.ts +50 -2
  104. package/src/index-global.ts +55 -0
  105. package/src/internal/demo-history-provider.test.ts +402 -0
  106. package/src/internal/demo-history-provider.ts +689 -0
  107. package/src/internal/history-provider-registry.ts +25 -0
  108. package/src/internal/history-provider.ts +150 -0
  109. package/src/internal/runtype-history-provider.test.ts +313 -0
  110. package/src/internal/runtype-history-provider.ts +240 -0
  111. package/src/plugin-kit.test.ts +16 -0
  112. package/src/plugin-kit.ts +13 -5
  113. package/src/plugins/types.ts +177 -1
  114. package/src/session-history.test.ts +1397 -0
  115. package/src/session.ts +1200 -26
  116. package/src/styles/widget-styles.test.ts +133 -0
  117. package/src/styles/widget.css +227 -8
  118. package/src/theme-editor/preview-utils.test.ts +19 -0
  119. package/src/theme-editor/preview-utils.ts +15 -1
  120. package/src/theme-editor/preview.ts +7 -1
  121. package/src/theme-editor/role-mappings.ts +4 -1
  122. package/src/theme-editor/sections.test.ts +168 -1
  123. package/src/theme-editor/sections.ts +136 -7
  124. package/src/theme-editor/webmcp/summary.ts +16 -2
  125. package/src/theme-reference.ts +20 -6
  126. package/src/types/theme.ts +213 -7
  127. package/src/types.ts +927 -7
  128. package/src/ui.artifact-rail-resize.test.ts +235 -0
  129. package/src/ui.attachments-drop.test.ts +1 -0
  130. package/src/ui.header-update-stability.test.ts +2 -2
  131. package/src/ui.history-chunk.test.ts +72 -0
  132. package/src/ui.history-config-provider.test.ts +318 -0
  133. package/src/ui.history-controller.test.ts +711 -0
  134. package/src/ui.history-lifecycle.test.ts +456 -0
  135. package/src/ui.history-render-hooks.test.ts +904 -0
  136. package/src/ui.history-shell.test.ts +1887 -0
  137. package/src/ui.rail-overlay.test.ts +573 -0
  138. package/src/ui.rail-resize.test.ts +284 -0
  139. package/src/ui.rebuild-composer.test.ts +21 -0
  140. package/src/ui.render-welcome.test.ts +42 -0
  141. package/src/ui.shortcuts.test.ts +166 -0
  142. package/src/ui.ts +3071 -240
  143. package/src/utils/__fixtures__/client-conversation-history-wire.json +108 -0
  144. package/src/utils/artifact-file.ts +15 -0
  145. package/src/utils/artifact-gate.test.ts +4 -0
  146. package/src/utils/artifact-gate.ts +13 -17
  147. package/src/utils/artifact-resize.test.ts +28 -0
  148. package/src/utils/artifact-resize.ts +9 -2
  149. package/src/utils/buttons.ts +188 -1
  150. package/src/utils/config-merge.test.ts +91 -0
  151. package/src/utils/dropdown.ts +10 -1
  152. package/src/utils/history-messages.test.ts +284 -0
  153. package/src/utils/history-messages.ts +284 -0
  154. package/src/utils/icons.ts +3 -0
  155. package/src/utils/shortcuts.test.ts +288 -0
  156. package/src/utils/shortcuts.ts +221 -0
  157. package/src/utils/theme.test.ts +501 -4
  158. package/src/utils/theme.ts +42 -3
  159. package/src/utils/tokens.ts +153 -15
  160. package/src/utils/tooltip-timing.ts +4 -0
  161. package/src/utils/tooltip.test.ts +249 -7
  162. package/src/utils/tooltip.ts +146 -4
  163. package/src/utils/visitor-store.test.ts +425 -0
  164. package/src/utils/visitor-store.ts +337 -0
  165. package/dist/chunk-QJ4LV2KW.js +0 -1
@@ -0,0 +1,142 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ formatGroupLabel,
5
+ formatMessageCount,
6
+ formatRelativeTime,
7
+ groupConversations,
8
+ resolveGroupKey,
9
+ } from "./grouping";
10
+ import { HISTORY_VIEW_COPY_DEFAULTS as COPY } from "./copy";
11
+ import type { HistoryConversationSummary } from "../../internal/history-provider";
12
+
13
+ const NOW = new Date(2026, 2, 15, 12, 0, 0).getTime();
14
+ const MIN = 60_000;
15
+ const HOUR = 60 * MIN;
16
+ const DAY = 24 * HOUR;
17
+
18
+ const at = (offsetMs: number): string => new Date(NOW - offsetMs).toISOString();
19
+
20
+ const summary = (
21
+ id: string,
22
+ offsetMs: number
23
+ ): HistoryConversationSummary => ({
24
+ id,
25
+ title: id,
26
+ targetId: "t1",
27
+ preview: null,
28
+ messageCount: 2,
29
+ createdAt: at(offsetMs),
30
+ updatedAt: at(offsetMs),
31
+ });
32
+
33
+ describe("history grouping", () => {
34
+ it("resolves calendar-relative group keys", () => {
35
+ expect(resolveGroupKey(at(30 * MIN), NOW)).toBe("today");
36
+ expect(resolveGroupKey(at(26 * HOUR), NOW)).toBe("yesterday");
37
+ expect(resolveGroupKey(at(4 * DAY), NOW)).toBe("previous-7-days");
38
+ expect(resolveGroupKey(at(20 * DAY), NOW)).toBe("previous-30-days");
39
+ expect(resolveGroupKey(at(100 * DAY), NOW)).toMatch(/^month:\d{4}-\d{1,2}$/);
40
+ });
41
+
42
+ it("treats future and unparseable timestamps as today", () => {
43
+ expect(resolveGroupKey(at(-5 * MIN), NOW)).toBe("today");
44
+ expect(resolveGroupKey("not-a-date", NOW)).toBe("today");
45
+ });
46
+
47
+ it("keeps server order and opens a group only when the key changes", () => {
48
+ const groups = groupConversations(
49
+ [
50
+ summary("a", 10 * MIN),
51
+ summary("b", 2 * HOUR),
52
+ summary("c", 26 * HOUR),
53
+ summary("d", 4 * DAY),
54
+ ],
55
+ NOW,
56
+ COPY
57
+ );
58
+ expect(groups.map((group) => group.key)).toEqual([
59
+ "today",
60
+ "yesterday",
61
+ "previous-7-days",
62
+ ]);
63
+ expect(groups[0].items.map((item) => item.id)).toEqual(["a", "b"]);
64
+ expect(groups[0].label).toBe("Today");
65
+ });
66
+
67
+ it("folds the time buckets into one flat group with grouping none", () => {
68
+ const groups = groupConversations(
69
+ [
70
+ summary("a", 10 * MIN),
71
+ summary("b", 26 * HOUR),
72
+ summary("c", 100 * DAY),
73
+ ],
74
+ NOW,
75
+ COPY,
76
+ "none"
77
+ );
78
+ expect(groups.map((group) => group.key)).toEqual(["recent"]);
79
+ expect(groups[0].items.map((item) => item.id)).toEqual(["a", "b", "c"]);
80
+ // The label exists for the list's accessible name; the view renders it
81
+ // sr-only under the identical list heading.
82
+ expect(groups[0].label).toBe("Conversations");
83
+ });
84
+
85
+ it("keeps the pinned starred group with grouping none", () => {
86
+ const groups = groupConversations(
87
+ [
88
+ summary("a", 10 * MIN),
89
+ { ...summary("pinned", 26 * HOUR), starred: true },
90
+ summary("b", 4 * DAY),
91
+ ],
92
+ NOW,
93
+ COPY,
94
+ "none"
95
+ );
96
+ expect(groups.map((group) => group.key)).toEqual(["starred", "recent"]);
97
+ expect(groups[0].items.map((item) => item.id)).toEqual(["pinned"]);
98
+ expect(groups[1].items.map((item) => item.id)).toEqual(["a", "b"]);
99
+ });
100
+
101
+ it("returns only the starred group when every row is pinned under grouping none", () => {
102
+ const groups = groupConversations(
103
+ [{ ...summary("pinned", 26 * HOUR), starred: true }],
104
+ NOW,
105
+ COPY,
106
+ "none"
107
+ );
108
+ expect(groups.map((group) => group.key)).toEqual(["starred"]);
109
+ });
110
+
111
+ it("does not reorder rows that arrive out of recency order", () => {
112
+ const groups = groupConversations(
113
+ [summary("old", 4 * DAY), summary("new", 10 * MIN)],
114
+ NOW,
115
+ COPY
116
+ );
117
+ expect(groups.map((group) => group.key)).toEqual([
118
+ "previous-7-days",
119
+ "today",
120
+ ]);
121
+ });
122
+
123
+ it("labels month groups from the item timestamp", () => {
124
+ const key = resolveGroupKey(at(100 * DAY), NOW);
125
+ const label = formatGroupLabel(key, at(100 * DAY), NOW, COPY);
126
+ expect(label).toMatch(/^[A-Za-z]+ \d{4}$/);
127
+ });
128
+
129
+ it("formats short unwrapped relative time", () => {
130
+ expect(formatRelativeTime(at(20_000), NOW, COPY)).toBe("now");
131
+ expect(formatRelativeTime(at(12 * MIN), NOW, COPY)).toBe("12m");
132
+ expect(formatRelativeTime(at(2 * HOUR), NOW, COPY)).toBe("2h");
133
+ expect(formatRelativeTime(at(3 * DAY), NOW, COPY)).toBe("3d");
134
+ expect(formatRelativeTime(at(20 * DAY), NOW, COPY)).toBe("2w");
135
+ expect(formatRelativeTime(at(800 * DAY), NOW, COPY)).toBe("2y");
136
+ });
137
+
138
+ it("pluralizes the message count", () => {
139
+ expect(formatMessageCount(1, COPY)).toBe("1 message");
140
+ expect(formatMessageCount(4, COPY)).toBe("4 messages");
141
+ });
142
+ });
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Client-side time grouping and relative-time formatting for the Messages list.
3
+ *
4
+ * Grouping never reorders: it walks the server order and opens a new group
5
+ * whenever the resolved key changes, so an appended page merges into the
6
+ * trailing group when its first item shares that group's key.
7
+ */
8
+
9
+ import { fillTemplate, type ResolvedHistoryViewCopy } from "./copy";
10
+ import type { HistoryConversationSummary } from "../../internal/history-provider";
11
+
12
+ export type HistoryGroupKey =
13
+ | "starred"
14
+ | "today"
15
+ | "yesterday"
16
+ | "previous-7-days"
17
+ | "previous-30-days"
18
+ /** `month:<year>-<zero-based month>` */
19
+ | `month:${string}`
20
+ /** The single flat group `grouping: "none"` folds the time buckets into. */
21
+ | "recent";
22
+
23
+ /** `features.history.grouping`. Starred rows keep their pinned group in both. */
24
+ export type HistoryGroupingMode = "time" | "none";
25
+
26
+ export interface HistoryGroup {
27
+ key: HistoryGroupKey;
28
+ label: string;
29
+ items: HistoryConversationSummary[];
30
+ }
31
+
32
+ const DAY_MS = 86_400_000;
33
+
34
+ function startOfLocalDay(ms: number): number {
35
+ const date = new Date(ms);
36
+ date.setHours(0, 0, 0, 0);
37
+ return date.getTime();
38
+ }
39
+
40
+ /** Unparseable timestamps sort into the newest group rather than disappearing. */
41
+ function parse(timestamp: string, fallback: number): number {
42
+ const ms = Date.parse(timestamp);
43
+ return Number.isFinite(ms) ? ms : fallback;
44
+ }
45
+
46
+ export function resolveGroupKey(
47
+ updatedAt: string,
48
+ nowMs: number
49
+ ): HistoryGroupKey {
50
+ const ms = parse(updatedAt, nowMs);
51
+ const today = startOfLocalDay(nowMs);
52
+ if (ms >= today) return "today";
53
+ if (ms >= today - DAY_MS) return "yesterday";
54
+ if (ms >= today - 7 * DAY_MS) return "previous-7-days";
55
+ if (ms >= today - 30 * DAY_MS) return "previous-30-days";
56
+ const date = new Date(ms);
57
+ return `month:${date.getFullYear()}-${date.getMonth()}`;
58
+ }
59
+
60
+ export function formatGroupLabel(
61
+ key: HistoryGroupKey,
62
+ updatedAt: string,
63
+ nowMs: number,
64
+ copy: ResolvedHistoryViewCopy
65
+ ): string {
66
+ if (key === "today") return copy.groupToday;
67
+ if (key === "yesterday") return copy.groupYesterday;
68
+ if (key === "previous-7-days") return copy.groupPrevious7Days;
69
+ if (key === "previous-30-days") return copy.groupPrevious30Days;
70
+ const date = new Date(parse(updatedAt, nowMs));
71
+ return fillTemplate(copy.groupMonthYear, {
72
+ month: date.toLocaleString(undefined, { month: "long" }),
73
+ year: date.getFullYear(),
74
+ });
75
+ }
76
+
77
+ export function groupConversations(
78
+ items: readonly HistoryConversationSummary[],
79
+ nowMs: number,
80
+ copy: ResolvedHistoryViewCopy,
81
+ grouping: HistoryGroupingMode = "time"
82
+ ): HistoryGroup[] {
83
+ // Starred rows pin into one leading group (server order preserved within
84
+ // it); everything else keeps the time grouping.
85
+ const starred = items.filter((item) => item.starred);
86
+ const groups: HistoryGroup[] =
87
+ starred.length > 0
88
+ ? [{ key: "starred", label: copy.groupStarred, items: starred }]
89
+ : [];
90
+ if (grouping === "none") {
91
+ // One flat group. Its label exists for the list's accessible name only:
92
+ // the view renders it sr-only, since the list heading directly above
93
+ // already says the same thing.
94
+ const rest = items.filter((item) => !item.starred);
95
+ if (rest.length > 0) {
96
+ groups.push({ key: "recent", label: copy.conversationsTitle, items: rest });
97
+ }
98
+ return groups;
99
+ }
100
+ for (const item of items) {
101
+ if (item.starred) continue;
102
+ const key = resolveGroupKey(item.updatedAt, nowMs);
103
+ const current = groups[groups.length - 1];
104
+ if (current && current.key === key) {
105
+ current.items.push(item);
106
+ continue;
107
+ }
108
+ groups.push({
109
+ key,
110
+ label: formatGroupLabel(key, item.updatedAt, nowMs, copy),
111
+ items: [item],
112
+ });
113
+ }
114
+ return groups;
115
+ }
116
+
117
+ /** Short, unwrapped, ambient form: "now", "12m", "2h", "3d", "5w", "2y". */
118
+ export function formatRelativeTime(
119
+ updatedAt: string,
120
+ nowMs: number,
121
+ copy: ResolvedHistoryViewCopy
122
+ ): string {
123
+ const elapsed = Math.max(0, nowMs - parse(updatedAt, nowMs));
124
+ const minutes = Math.floor(elapsed / 60_000);
125
+ if (minutes < 1) return copy.relativeNow;
126
+ if (minutes < 60) return fillTemplate(copy.relativeMinutes, { value: minutes });
127
+ const hours = Math.floor(minutes / 60);
128
+ if (hours < 24) return fillTemplate(copy.relativeHours, { value: hours });
129
+ const days = Math.floor(hours / 24);
130
+ if (days < 7) return fillTemplate(copy.relativeDays, { value: days });
131
+ const weeks = Math.floor(days / 7);
132
+ if (days < 365) return fillTemplate(copy.relativeWeeks, { value: weeks });
133
+ return fillTemplate(copy.relativeYears, { value: Math.floor(days / 365) });
134
+ }
135
+
136
+ export function formatMessageCount(
137
+ count: number,
138
+ copy: ResolvedHistoryViewCopy
139
+ ): string {
140
+ if (count === 1) return copy.messageCountLabelOne;
141
+ return fillTemplate(copy.messageCountLabel, { count });
142
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The lucide glyphs the Messages view needs, inlined.
3
+ *
4
+ * `utils/icons.ts` would drag the whole ~130-icon registry into this lazy
5
+ * chunk; the chunk exists to stay small. Path data matches lucide 1:1.
6
+ */
7
+
8
+ const SVG_NS = "http://www.w3.org/2000/svg";
9
+
10
+ const PATHS = {
11
+ "arrow-left": ["m12 19-7-7 7-7", "M19 12H5"],
12
+ plus: ["M5 12h14", "M12 5v14"],
13
+ // Rendered filled at ~11px, so a plain 5-point polygon beats lucide's long
14
+ // rounded path byte-for-byte with no visible difference.
15
+ star: ["m12 2 2.9 6.2 6.8.7-5.1 4.5 1.4 6.6-6-3.4-6 3.4 1.4-6.6-5.1-4.5 6.8-.7z"],
16
+ x: ["M18 6 6 18", "m6 6 12 12"],
17
+ // PanelLeft is a rounded rect plus this divider; the rect is added below.
18
+ "panel-left": ["M9 3v18"],
19
+ // Monitor is a rounded rect (added below) plus the stand.
20
+ monitor: ["M8 21h8", "M12 17v4"],
21
+ ellipsis: [] as string[],
22
+ } as const;
23
+
24
+ /** The rounded-rect halves of the two-part glyphs above. */
25
+ const RECTS: Partial<Record<keyof typeof PATHS, [number, number, number, number]>> = {
26
+ "panel-left": [3, 3, 18, 18],
27
+ monitor: [2, 3, 20, 14],
28
+ };
29
+
30
+ export type HistoryIconName = keyof typeof PATHS;
31
+
32
+ export function historyIcon(name: HistoryIconName, size = 20): SVGElement {
33
+ const svg = document.createElementNS(SVG_NS, "svg");
34
+ svg.setAttribute("width", String(size));
35
+ svg.setAttribute("height", String(size));
36
+ svg.setAttribute("viewBox", "0 0 24 24");
37
+ svg.setAttribute("fill", "none");
38
+ svg.setAttribute("stroke", "currentColor");
39
+ svg.setAttribute("stroke-width", "2");
40
+ svg.setAttribute("stroke-linecap", "round");
41
+ svg.setAttribute("stroke-linejoin", "round");
42
+ svg.setAttribute("aria-hidden", "true");
43
+ svg.setAttribute("focusable", "false");
44
+
45
+ if (name === "ellipsis") {
46
+ for (const cx of [12, 19, 5]) {
47
+ const circle = document.createElementNS(SVG_NS, "circle");
48
+ circle.setAttribute("cx", String(cx));
49
+ circle.setAttribute("cy", "12");
50
+ circle.setAttribute("r", "1");
51
+ svg.appendChild(circle);
52
+ }
53
+ return svg;
54
+ }
55
+
56
+ const box = RECTS[name];
57
+ if (box) {
58
+ const rect = document.createElementNS(SVG_NS, "rect");
59
+ rect.setAttribute("x", String(box[0]));
60
+ rect.setAttribute("y", String(box[1]));
61
+ rect.setAttribute("width", String(box[2]));
62
+ rect.setAttribute("height", String(box[3]));
63
+ rect.setAttribute("rx", "2");
64
+ svg.appendChild(rect);
65
+ }
66
+
67
+ for (const d of PATHS[name]) {
68
+ const path = document.createElementNS(SVG_NS, "path");
69
+ path.setAttribute("d", d);
70
+ svg.appendChild(path);
71
+ }
72
+ return svg;
73
+ }
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Conversation row + its overflow menu.
3
+ *
4
+ * The row is a whole-row button; the overflow trigger is a SIBLING absolutely
5
+ * positioned over it, because a button cannot legally nest inside a button.
6
+ *
7
+ * Hierarchy: avatar, then title over preview with the relative time aligned to
8
+ * the title. A conversation without a server title promotes its preview to the
9
+ * title line rather than rendering an empty one; nothing is derived locally.
10
+ */
11
+
12
+ import { createNode, cx } from "../../utils/dom";
13
+ import { historyIcon } from "./icons";
14
+ import { formatMessageCount, formatRelativeTime } from "./grouping";
15
+ import type { ResolvedHistoryViewCopy } from "./copy";
16
+ import type { HistoryConversationSummary } from "../../internal/history-provider";
17
+
18
+ export type RowPending = "opening" | "deleting" | null;
19
+
20
+ export interface ConversationRowOptions {
21
+ conversation: HistoryConversationSummary;
22
+ active: boolean;
23
+ pending: RowPending;
24
+ /** A view-level action is in flight; every row action is inert. */
25
+ busy: boolean;
26
+ menuOpen: boolean;
27
+ /** Row-adjacent, retryable failure text. */
28
+ error: { message: string; retry: () => void } | null;
29
+ /** Image URL or glyph for the leading avatar; empty/false omits the block. */
30
+ avatar: string | false | undefined;
31
+ /** Row overflow menu (delete). False drops the trigger entirely. */
32
+ showDelete: boolean;
33
+ nowMs: number;
34
+ copy: ResolvedHistoryViewCopy;
35
+ onOpen: () => void;
36
+ onToggleMenu: () => void;
37
+ onCloseMenu: (options?: { restoreFocus?: boolean }) => void;
38
+ onDelete: () => void;
39
+ }
40
+
41
+ /** Same source shape as the header icon: a URL renders as an image, else a glyph. */
42
+ const AVATAR_URL = /^(https?:|\/|data:)/i;
43
+
44
+ function buildAvatar(source: string): HTMLElement {
45
+ const holder = createNode("span", {
46
+ className: "persona-history-row-avatar",
47
+ attrs: { "aria-hidden": "true" },
48
+ });
49
+ if (AVATAR_URL.test(source)) {
50
+ holder.appendChild(
51
+ createNode("img", { attrs: { src: source, alt: "", loading: "lazy" } })
52
+ );
53
+ } else {
54
+ holder.textContent = source;
55
+ }
56
+ return holder;
57
+ }
58
+
59
+ export const rowFocusKey = (id: string): string => `row:${id}`;
60
+ export const menuFocusKey = (id: string): string => `menu:${id}`;
61
+ export const menuItemFocusKey = (id: string): string => `menu-item:${id}`;
62
+
63
+ export function buildConversationRow(
64
+ options: ConversationRowOptions
65
+ ): HTMLLIElement {
66
+ const { conversation, copy, busy, pending } = options;
67
+ const inert = busy || pending !== null;
68
+
69
+ // A placeholder-titled conversation promotes its preview instead of showing
70
+ // an empty title line above it.
71
+ const titled = conversation.title.trim().length > 0;
72
+ // Filled miniature of the chunk's stroke star; sr text reuses the group label.
73
+ const starMark = conversation.starred
74
+ ? (() => {
75
+ const glyph = historyIcon("star", 11);
76
+ glyph.setAttribute("fill", "currentColor");
77
+ glyph.setAttribute("stroke-width", "1");
78
+ return createNode(
79
+ "span",
80
+ { className: "persona-history-row-star" },
81
+ glyph,
82
+ createNode("span", {
83
+ className: "persona-history-sr-only",
84
+ text: copy.groupStarred,
85
+ })
86
+ );
87
+ })()
88
+ : null;
89
+
90
+ const head = createNode(
91
+ "div",
92
+ { className: "persona-history-row-head" },
93
+ createNode("span", {
94
+ className: "persona-history-row-title persona-history-truncate",
95
+ text: titled ? conversation.title : (conversation.preview ?? ""),
96
+ }),
97
+ starMark,
98
+ createNode("time", {
99
+ className: "persona-history-row-time",
100
+ attrs: { datetime: conversation.updatedAt },
101
+ text: formatRelativeTime(conversation.updatedAt, options.nowMs, copy),
102
+ })
103
+ );
104
+
105
+ const preview =
106
+ titled && conversation.preview
107
+ ? createNode("span", {
108
+ className: "persona-history-row-preview persona-history-truncate",
109
+ text: conversation.preview,
110
+ })
111
+ : null;
112
+
113
+ const rowButton = createNode(
114
+ "button",
115
+ {
116
+ className: cx(
117
+ "persona-history-row",
118
+ options.active && "persona-history-row--active",
119
+ pending && `persona-history-row--${pending}`
120
+ ),
121
+ attrs: {
122
+ type: "button",
123
+ "data-persona-history-focus": rowFocusKey(conversation.id),
124
+ "data-persona-history-conversation": conversation.id,
125
+ ...(options.active ? { "aria-current": "page" } : {}),
126
+ ...(inert ? { "aria-disabled": "true" } : {}),
127
+ ...(pending ? { "aria-busy": "true" } : {}),
128
+ },
129
+ },
130
+ options.avatar ? buildAvatar(options.avatar) : null,
131
+ createNode(
132
+ "div",
133
+ { className: "persona-history-row-body" },
134
+ head,
135
+ preview,
136
+ createNode("span", {
137
+ className: "persona-history-row-count persona-history-sr-only",
138
+ text: formatMessageCount(conversation.messageCount, copy),
139
+ })
140
+ )
141
+ );
142
+ rowButton.addEventListener("click", () => {
143
+ if (inert) return;
144
+ options.onOpen();
145
+ });
146
+
147
+ const menuButton = options.showDelete
148
+ ? buildMenuTrigger({
149
+ className: "persona-history-row-menu-button",
150
+ label: `${copy.rowActionsLabel}: ${conversation.title}`,
151
+ focusKey: menuFocusKey(conversation.id),
152
+ open: options.menuOpen,
153
+ inert,
154
+ onToggle: options.onToggleMenu,
155
+ })
156
+ : null;
157
+
158
+ const item = createNode(
159
+ "li",
160
+ {
161
+ className: cx(
162
+ "persona-history-item",
163
+ !options.showDelete && "persona-history-item--no-menu"
164
+ ),
165
+ attrs: { "data-persona-history-item": conversation.id },
166
+ },
167
+ rowButton,
168
+ menuButton,
169
+ options.menuOpen && options.showDelete ? buildRowMenu(options) : null,
170
+ options.error ? buildRowError(options.error, copy, conversation.id) : null
171
+ );
172
+ return item;
173
+ }
174
+
175
+ function buildRowMenu(options: ConversationRowOptions): HTMLElement {
176
+ const { conversation, copy } = options;
177
+ return buildOverflowMenu({
178
+ label: `${copy.rowActionsLabel}: ${conversation.title}`,
179
+ items: [
180
+ {
181
+ label: copy.deleteConversationLabel,
182
+ focusKey: menuItemFocusKey(conversation.id),
183
+ danger: true,
184
+ onSelect: options.onDelete,
185
+ },
186
+ ],
187
+ onCloseMenu: options.onCloseMenu,
188
+ });
189
+ }
190
+
191
+ export interface MenuTriggerOptions {
192
+ /** Added to the shared icon-button class. */
193
+ className: string;
194
+ label: string;
195
+ focusKey: string;
196
+ open: boolean;
197
+ /** Keeps focus but does not open. Read live, so an owner may re-set it. */
198
+ inert: boolean;
199
+ iconSize?: number;
200
+ onToggle: () => void;
201
+ }
202
+
203
+ /**
204
+ * The ellipsis trigger both menus hang off. State is read back from the
205
+ * element, so an owner that mutates the attributes in place stays consistent.
206
+ */
207
+ export function buildMenuTrigger(options: MenuTriggerOptions): HTMLElement {
208
+ const button = createNode("button", {
209
+ className: cx("persona-history-icon-button", options.className),
210
+ attrs: {
211
+ type: "button",
212
+ "aria-label": options.label,
213
+ "aria-haspopup": "menu",
214
+ "aria-expanded": options.open ? "true" : "false",
215
+ "data-persona-history-focus": options.focusKey,
216
+ ...(options.inert ? { "aria-disabled": "true" } : {}),
217
+ },
218
+ });
219
+ button.appendChild(historyIcon("ellipsis", options.iconSize));
220
+ const blocked = (): boolean =>
221
+ button.getAttribute("aria-disabled") === "true";
222
+ button.addEventListener("click", (event) => {
223
+ event.stopPropagation();
224
+ if (blocked()) return;
225
+ options.onToggle();
226
+ });
227
+ button.addEventListener("keydown", (event) => {
228
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
229
+ event.preventDefault();
230
+ if (blocked() || button.getAttribute("aria-expanded") === "true") return;
231
+ options.onToggle();
232
+ });
233
+ return button;
234
+ }
235
+
236
+ export interface HistoryMenuItemSpec {
237
+ label: string;
238
+ /** `data-persona-history-focus` value, so a re-render can land focus back. */
239
+ focusKey: string;
240
+ /** Destructive (red) styling. Host list actions default to neutral text. */
241
+ danger?: boolean;
242
+ onSelect: () => void;
243
+ }
244
+
245
+ export interface OverflowMenuOptions {
246
+ /** Accessible name of the menu itself. */
247
+ label: string;
248
+ items: HistoryMenuItemSpec[];
249
+ onCloseMenu: (options?: { restoreFocus?: boolean }) => void;
250
+ }
251
+
252
+ /**
253
+ * The one popover shape this view uses: rows and the list-level trigger share
254
+ * it, so keyboard behaviour is defined once.
255
+ */
256
+ export function buildOverflowMenu(options: OverflowMenuOptions): HTMLElement {
257
+ const menu = createNode("div", {
258
+ className: "persona-history-menu",
259
+ attrs: { role: "menu", "aria-label": options.label },
260
+ });
261
+
262
+ for (const spec of options.items) {
263
+ const item = createNode("button", {
264
+ className: cx(
265
+ "persona-history-menu-item",
266
+ spec.danger && "persona-history-menu-item--danger"
267
+ ),
268
+ text: spec.label,
269
+ attrs: {
270
+ type: "button",
271
+ role: "menuitem",
272
+ tabindex: "0",
273
+ "data-persona-history-focus": spec.focusKey,
274
+ },
275
+ });
276
+ item.addEventListener("click", () => {
277
+ options.onCloseMenu();
278
+ spec.onSelect();
279
+ });
280
+ menu.appendChild(item);
281
+ }
282
+
283
+ menu.addEventListener("keydown", (event) => {
284
+ const items = Array.from(
285
+ menu.querySelectorAll<HTMLElement>('[role="menuitem"]')
286
+ );
287
+ if (items.length === 0) return;
288
+ const index = items.indexOf(document.activeElement as HTMLElement);
289
+ // Escape is handled at the view root so it can stop propagation before the
290
+ // shell's own Escape handler sees it.
291
+ if (event.key === "Tab") {
292
+ options.onCloseMenu({ restoreFocus: true });
293
+ return;
294
+ }
295
+ if (event.key === "ArrowDown") {
296
+ event.preventDefault();
297
+ items[(index + 1 + items.length) % items.length]?.focus();
298
+ return;
299
+ }
300
+ if (event.key === "ArrowUp") {
301
+ event.preventDefault();
302
+ items[(index - 1 + items.length) % items.length]?.focus();
303
+ return;
304
+ }
305
+ if (event.key === "Home") {
306
+ event.preventDefault();
307
+ items[0]?.focus();
308
+ return;
309
+ }
310
+ if (event.key === "End") {
311
+ event.preventDefault();
312
+ items[items.length - 1]?.focus();
313
+ }
314
+ });
315
+ return menu;
316
+ }
317
+
318
+ function buildRowError(
319
+ error: { message: string; retry: () => void },
320
+ copy: ResolvedHistoryViewCopy,
321
+ conversationId: string
322
+ ): HTMLElement {
323
+ const retry = createNode("button", {
324
+ className: "persona-history-secondary persona-history-state-action",
325
+ text: copy.retryLabel,
326
+ attrs: {
327
+ type: "button",
328
+ "data-persona-history-focus": `row-retry:${conversationId}`,
329
+ },
330
+ });
331
+ retry.addEventListener("click", error.retry);
332
+ return createNode(
333
+ "div",
334
+ {
335
+ className: "persona-history-row-error",
336
+ attrs: { role: "alert" },
337
+ },
338
+ createNode("span", { text: error.message }),
339
+ retry
340
+ );
341
+ }