@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.
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-BpWGm7_6.d.cts → types-DsmTD8TO.d.cts} +12 -0
- package/dist/animations/{types-BpWGm7_6.d.ts → types-DsmTD8TO.d.ts} +12 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- 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 +1224 -14
- package/dist/context-mentions.d.ts +1224 -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 +2040 -21
- package/dist/index.d.ts +2040 -21
- 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 +1224 -14
- package/dist/smart-dom-reader.d.ts +1224 -14
- package/dist/theme-editor-preview.cjs +970 -73
- package/dist/theme-editor-preview.d.cts +1343 -15
- package/dist/theme-editor-preview.d.ts +1343 -15
- package/dist/theme-editor-preview.js +81 -81
- package/dist/theme-editor.cjs +7 -7
- package/dist/theme-editor.d.cts +1227 -15
- package/dist/theme-editor.d.ts +1227 -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/webmcp-polyfill.js +2 -2
- package/dist/widget.css +1 -1
- package/package.json +18 -6
- 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 +54 -4
- 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,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
|
+
}
|