@classic-homes/agent-chat 0.11.0 → 0.13.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 (117) hide show
  1. package/README.md +67 -2
  2. package/dist/components/AgentChat.svelte +60 -25
  3. package/dist/components/AgentChat.svelte.d.ts +11 -1
  4. package/dist/components/AgentChat.svelte.d.ts.map +1 -1
  5. package/dist/components/ArtifactPanel.svelte +4 -2
  6. package/dist/components/AssistantMessage.svelte +58 -16
  7. package/dist/components/AssistantMessage.svelte.d.ts +2 -0
  8. package/dist/components/AssistantMessage.svelte.d.ts.map +1 -1
  9. package/dist/components/ChatHeader.svelte +4 -1
  10. package/dist/components/ChatHeader.svelte.d.ts +2 -0
  11. package/dist/components/ChatHeader.svelte.d.ts.map +1 -1
  12. package/dist/components/ChatLauncher.svelte +175 -0
  13. package/dist/components/ChatLauncher.svelte.d.ts +26 -0
  14. package/dist/components/ChatLauncher.svelte.d.ts.map +1 -0
  15. package/dist/components/Composer.svelte +7 -3
  16. package/dist/components/Composer.svelte.d.ts.map +1 -1
  17. package/dist/components/FloatingWindow.svelte +280 -0
  18. package/dist/components/FloatingWindow.svelte.d.ts +35 -0
  19. package/dist/components/FloatingWindow.svelte.d.ts.map +1 -0
  20. package/dist/components/Markdown.svelte +4 -0
  21. package/dist/components/Markdown.svelte.d.ts.map +1 -1
  22. package/dist/components/MessageFeedback.svelte +11 -4
  23. package/dist/components/MessageFeedback.svelte.d.ts.map +1 -1
  24. package/dist/components/MessageFiles.svelte +133 -40
  25. package/dist/components/MessageFiles.svelte.d.ts +6 -0
  26. package/dist/components/MessageFiles.svelte.d.ts.map +1 -1
  27. package/dist/components/MessageItem.svelte +2 -0
  28. package/dist/components/MessageItem.svelte.d.ts +2 -0
  29. package/dist/components/MessageItem.svelte.d.ts.map +1 -1
  30. package/dist/components/MessageList.svelte +40 -7
  31. package/dist/components/MessageList.svelte.d.ts +3 -1
  32. package/dist/components/MessageList.svelte.d.ts.map +1 -1
  33. package/dist/components/ShortcutsDialog.svelte +10 -3
  34. package/dist/components/ShortcutsDialog.svelte.d.ts +2 -0
  35. package/dist/components/ShortcutsDialog.svelte.d.ts.map +1 -1
  36. package/dist/components/UiFrame.svelte +12 -4
  37. package/dist/components/UiFrame.svelte.d.ts.map +1 -1
  38. package/dist/components/UserMessage.svelte +10 -1
  39. package/dist/components/UserMessage.svelte.d.ts.map +1 -1
  40. package/dist/index.d.ts +5 -0
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +5 -0
  43. package/dist/markdown.d.ts.map +1 -1
  44. package/dist/markdown.js +94 -61
  45. package/dist/reducer.d.ts +11 -0
  46. package/dist/reducer.d.ts.map +1 -1
  47. package/dist/reducer.js +18 -2
  48. package/dist/session.svelte.d.ts +40 -0
  49. package/dist/session.svelte.d.ts.map +1 -1
  50. package/dist/session.svelte.js +178 -23
  51. package/dist/shortcuts.d.ts +7 -0
  52. package/dist/shortcuts.d.ts.map +1 -1
  53. package/dist/shortcuts.js +9 -0
  54. package/dist/surface-geometry.d.ts +60 -0
  55. package/dist/surface-geometry.d.ts.map +1 -0
  56. package/dist/surface-geometry.js +112 -0
  57. package/dist/surface.svelte.d.ts +79 -0
  58. package/dist/surface.svelte.d.ts.map +1 -0
  59. package/dist/surface.svelte.js +178 -0
  60. package/dist/thinking.d.ts +10 -0
  61. package/dist/thinking.d.ts.map +1 -0
  62. package/dist/thinking.js +61 -0
  63. package/dist/ui-frame-protocol.d.ts +6 -0
  64. package/dist/ui-frame-protocol.d.ts.map +1 -1
  65. package/dist/ui-frame-protocol.js +11 -0
  66. package/package.json +8 -7
  67. package/dist/artifact-pages.test.d.ts +0 -2
  68. package/dist/artifact-pages.test.d.ts.map +0 -1
  69. package/dist/artifact-pages.test.js +0 -102
  70. package/dist/components/a11y.svelte.test.d.ts +0 -2
  71. package/dist/components/a11y.svelte.test.d.ts.map +0 -1
  72. package/dist/components/a11y.svelte.test.js +0 -183
  73. package/dist/components/agent-picker.svelte.test.d.ts +0 -2
  74. package/dist/components/agent-picker.svelte.test.d.ts.map +0 -1
  75. package/dist/components/agent-picker.svelte.test.js +0 -84
  76. package/dist/components/composer.svelte.test.d.ts +0 -2
  77. package/dist/components/composer.svelte.test.d.ts.map +0 -1
  78. package/dist/components/composer.svelte.test.js +0 -162
  79. package/dist/components/continue.svelte.test.d.ts +0 -2
  80. package/dist/components/continue.svelte.test.d.ts.map +0 -1
  81. package/dist/components/continue.svelte.test.js +0 -109
  82. package/dist/components/conversation-chrome.svelte.test.d.ts +0 -2
  83. package/dist/components/conversation-chrome.svelte.test.d.ts.map +0 -1
  84. package/dist/components/conversation-chrome.svelte.test.js +0 -147
  85. package/dist/components/feedback.svelte.test.d.ts +0 -2
  86. package/dist/components/feedback.svelte.test.d.ts.map +0 -1
  87. package/dist/components/feedback.svelte.test.js +0 -99
  88. package/dist/components/live-fixes.svelte.test.d.ts +0 -2
  89. package/dist/components/live-fixes.svelte.test.d.ts.map +0 -1
  90. package/dist/components/live-fixes.svelte.test.js +0 -127
  91. package/dist/components/markdown-prose.svelte.test.d.ts +0 -2
  92. package/dist/components/markdown-prose.svelte.test.d.ts.map +0 -1
  93. package/dist/components/markdown-prose.svelte.test.js +0 -26
  94. package/dist/components/messages.svelte.test.d.ts +0 -2
  95. package/dist/components/messages.svelte.test.d.ts.map +0 -1
  96. package/dist/components/messages.svelte.test.js +0 -215
  97. package/dist/components/regenerate.svelte.test.d.ts +0 -2
  98. package/dist/components/regenerate.svelte.test.d.ts.map +0 -1
  99. package/dist/components/regenerate.svelte.test.js +0 -108
  100. package/dist/components/welcome.svelte.test.d.ts +0 -2
  101. package/dist/components/welcome.svelte.test.d.ts.map +0 -1
  102. package/dist/components/welcome.svelte.test.js +0 -119
  103. package/dist/composer-keys.test.d.ts +0 -2
  104. package/dist/composer-keys.test.d.ts.map +0 -1
  105. package/dist/composer-keys.test.js +0 -16
  106. package/dist/markdown.test.d.ts +0 -2
  107. package/dist/markdown.test.d.ts.map +0 -1
  108. package/dist/markdown.test.js +0 -35
  109. package/dist/reducer.test.d.ts +0 -2
  110. package/dist/reducer.test.d.ts.map +0 -1
  111. package/dist/reducer.test.js +0 -153
  112. package/dist/ui-frame-protocol.test.d.ts +0 -2
  113. package/dist/ui-frame-protocol.test.d.ts.map +0 -1
  114. package/dist/ui-frame-protocol.test.js +0 -48
  115. package/dist/ui-frame-relay.test.d.ts +0 -2
  116. package/dist/ui-frame-relay.test.d.ts.map +0 -1
  117. package/dist/ui-frame-relay.test.js +0 -36
@@ -0,0 +1,178 @@
1
+ import { CORNERS, LAUNCHER_SIZE, clampLauncher, clampRect, defaultRect, nearestCorner, resizeRect, } from './surface-geometry.js';
2
+ const isPoint = (value) => typeof value === 'object' &&
3
+ value !== null &&
4
+ ['x', 'y'].every((key) => Number.isFinite(value[key]));
5
+ const isRect = (value) => typeof value === 'object' &&
6
+ value !== null &&
7
+ ['x', 'y', 'width', 'height'].every((key) => Number.isFinite(value[key]));
8
+ /**
9
+ * Reactive state of the detached chat (Svelte 5 runes class): which presentation is showing,
10
+ * where the launcher sits, the floating window's geometry and whether an answer arrived unseen.
11
+ * Layout survives reloads through `storageKey`; a saved window that no longer fits the viewport
12
+ * is pulled back inside.
13
+ */
14
+ export class ChatSurface {
15
+ mode = $state('launcher');
16
+ /** The open presentation the launcher returns to. */
17
+ lastOpen = $state('float');
18
+ corner = $state('bottom-right');
19
+ /**
20
+ * Where the user dropped the launcher (its top-left), or null while it sits in `corner`.
21
+ * Separate from the window's place: moving one never moves the other.
22
+ */
23
+ launcher = $state(null);
24
+ /** An answer finished while the chat was collapsed. Cleared on open. */
25
+ unread = $state(false);
26
+ /** True while the user drags or resizes the window. */
27
+ moving = $state(false);
28
+ saved = $state(null);
29
+ options;
30
+ constructor(options = {}) {
31
+ this.options = options;
32
+ this.lastOpen = options.defaultOpen ?? 'float';
33
+ this.corner = options.corner ?? 'bottom-right';
34
+ this.restore();
35
+ }
36
+ get isOpen() {
37
+ return this.mode !== 'launcher';
38
+ }
39
+ /** The floating window's rect; a default beside the launcher until the user moves it. */
40
+ get rect() {
41
+ const viewport = this.viewport();
42
+ return this.saved ? clampRect(this.saved, viewport) : defaultRect(viewport, this.corner);
43
+ }
44
+ open(mode = this.lastOpen) {
45
+ this.mode = mode;
46
+ this.lastOpen = mode;
47
+ this.unread = false;
48
+ this.persist();
49
+ }
50
+ minimize() {
51
+ this.mode = 'launcher';
52
+ this.persist();
53
+ }
54
+ toggle() {
55
+ if (this.isOpen)
56
+ this.minimize();
57
+ else
58
+ this.open();
59
+ }
60
+ dock() {
61
+ this.open('dock');
62
+ }
63
+ float() {
64
+ this.open('float');
65
+ }
66
+ /** A turn ended; flag it when the user cannot see the answer. */
67
+ notifyTurnEnd() {
68
+ if (!this.isOpen)
69
+ this.unread = true;
70
+ }
71
+ setCorner(corner) {
72
+ this.corner = corner;
73
+ this.launcher = null;
74
+ this.persist();
75
+ }
76
+ /**
77
+ * The user dropped the launcher at `point` (top-left). It stays there; `corner` follows the
78
+ * nearest one, which is where a window that was never moved opens.
79
+ */
80
+ setLauncher(point) {
81
+ const viewport = this.viewport();
82
+ this.launcher = clampLauncher(point, viewport);
83
+ this.corner = nearestCorner({ x: this.launcher.x + LAUNCHER_SIZE / 2, y: this.launcher.y + LAUNCHER_SIZE / 2 }, viewport);
84
+ this.persist();
85
+ }
86
+ /** Move the window by a pointer or key delta. Call `commit` when the gesture ends. */
87
+ moveBy(dx, dy) {
88
+ const rect = this.rect;
89
+ this.saved = clampRect({ ...rect, x: rect.x + dx, y: rect.y + dy }, this.viewport());
90
+ }
91
+ resizeBy(direction, dx, dy) {
92
+ this.saved = resizeRect(this.rect, direction, dx, dy, this.viewport());
93
+ }
94
+ /** End of a move or resize: remember the result. */
95
+ commit() {
96
+ this.moving = false;
97
+ this.persist();
98
+ }
99
+ /** Forget the user's geometry and return the window to its default place. */
100
+ resetRect() {
101
+ this.saved = null;
102
+ this.persist();
103
+ }
104
+ /**
105
+ * Keep the window inside the viewport as it changes size. Returns the cleanup; call from an
106
+ * effect while the floating window is mounted.
107
+ */
108
+ attach(target = window) {
109
+ const fit = () => {
110
+ if (this.saved)
111
+ this.saved = clampRect(this.saved, this.viewport());
112
+ };
113
+ fit();
114
+ target.addEventListener('resize', fit);
115
+ return () => target.removeEventListener('resize', fit);
116
+ }
117
+ viewport() {
118
+ if (this.options.viewport)
119
+ return this.options.viewport();
120
+ if (typeof window === 'undefined')
121
+ return { width: 1280, height: 800 };
122
+ return { width: window.innerWidth, height: window.innerHeight };
123
+ }
124
+ storage() {
125
+ if (!this.options.storageKey)
126
+ return undefined;
127
+ if (this.options.storage)
128
+ return this.options.storage;
129
+ try {
130
+ return typeof localStorage === 'undefined' ? undefined : localStorage;
131
+ }
132
+ catch {
133
+ return undefined;
134
+ }
135
+ }
136
+ restore() {
137
+ try {
138
+ const raw = this.storage()?.getItem(this.options.storageKey);
139
+ if (!raw)
140
+ return;
141
+ const data = JSON.parse(raw);
142
+ if (data.v !== 1)
143
+ return;
144
+ if (data.mode === 'launcher' || data.mode === 'float' || data.mode === 'dock')
145
+ this.mode = data.mode;
146
+ if (data.lastOpen === 'float' || data.lastOpen === 'dock')
147
+ this.lastOpen = data.lastOpen;
148
+ if (data.corner && CORNERS.includes(data.corner))
149
+ this.corner = data.corner;
150
+ if (isRect(data.rect))
151
+ this.saved = clampRect(data.rect, this.viewport());
152
+ if (isPoint(data.launcher))
153
+ this.launcher = clampLauncher({ x: data.launcher.x, y: data.launcher.y }, this.viewport());
154
+ }
155
+ catch {
156
+ // Unreadable or blocked storage: start from the defaults.
157
+ }
158
+ }
159
+ persist() {
160
+ try {
161
+ const data = {
162
+ v: 1,
163
+ mode: this.mode,
164
+ lastOpen: this.lastOpen,
165
+ corner: this.corner,
166
+ rect: this.saved,
167
+ launcher: this.launcher,
168
+ };
169
+ this.storage()?.setItem(this.options.storageKey, JSON.stringify(data));
170
+ }
171
+ catch {
172
+ // Storage full or blocked: layout just is not remembered.
173
+ }
174
+ }
175
+ }
176
+ export function createChatSurface(options = {}) {
177
+ return new ChatSurface(options);
178
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * What the assistant shows while it works and has nothing to say yet: homebuilding verbs in
3
+ * place of a bare "Thinking" (the wording the Classic Chat widget uses).
4
+ */
5
+ export declare const THINKING_PHRASES: readonly string[];
6
+ /** How long one phrase shows before the next. */
7
+ export declare const THINKING_PHRASE_MS = 4000;
8
+ /** A random phrase, never `previous` twice in a row. */
9
+ export declare function pickThinkingPhrase(previous?: string, random?: () => number): string;
10
+ //# sourceMappingURL=thinking.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"thinking.d.ts","sourceRoot":"","sources":["../src/lib/thinking.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,eAAO,MAAM,gBAAgB,EAAE,SAAS,MAAM,EAiD7C,CAAC;AAEF,iDAAiD;AACjD,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAEvC,wDAAwD;AACxD,wBAAgB,kBAAkB,CAAC,QAAQ,CAAC,EAAE,MAAM,EAAE,MAAM,GAAE,MAAM,MAAoB,GAAG,MAAM,CAGhG"}
@@ -0,0 +1,61 @@
1
+ /**
2
+ * What the assistant shows while it works and has nothing to say yet: homebuilding verbs in
3
+ * place of a bare "Thinking" (the wording the Classic Chat widget uses).
4
+ */
5
+ export const THINKING_PHRASES = [
6
+ 'Framing',
7
+ 'Drafting',
8
+ 'Surveying',
9
+ 'Grading',
10
+ 'Excavating',
11
+ 'Leveling',
12
+ 'Measuring',
13
+ 'Squaring',
14
+ 'Pouring footings',
15
+ 'Pouring slab',
16
+ 'Laying foundation',
17
+ 'Raising walls',
18
+ 'Setting trusses',
19
+ 'Sheathing',
20
+ 'Roofing',
21
+ 'Nailing',
22
+ 'Checking plans',
23
+ 'Reading blueprints',
24
+ 'Pulling permits',
25
+ 'Scheduling inspections',
26
+ 'Sketching layouts',
27
+ 'Mapping lots',
28
+ 'Staking corners',
29
+ 'Wiring',
30
+ 'Plumbing',
31
+ 'Ducting',
32
+ 'Insulating',
33
+ 'Hanging drywall',
34
+ 'Mudding',
35
+ 'Sanding',
36
+ 'Priming',
37
+ 'Painting',
38
+ 'Laying tile',
39
+ 'Installing cabinets',
40
+ 'Setting countertops',
41
+ 'Hanging doors',
42
+ 'Trimming',
43
+ 'Flooring',
44
+ 'Picking finishes',
45
+ 'Matching paint',
46
+ 'Choosing fixtures',
47
+ 'Landscaping',
48
+ 'Touring models',
49
+ 'Comparing plans',
50
+ 'Building',
51
+ 'Punch list',
52
+ 'Final walkthrough',
53
+ 'Handing keys',
54
+ ];
55
+ /** How long one phrase shows before the next. */
56
+ export const THINKING_PHRASE_MS = 4000;
57
+ /** A random phrase, never `previous` twice in a row. */
58
+ export function pickThinkingPhrase(previous, random = Math.random) {
59
+ const pool = previous ? THINKING_PHRASES.filter((p) => p !== previous) : THINKING_PHRASES;
60
+ return pool[Math.min(pool.length - 1, Math.floor(random() * pool.length))];
61
+ }
@@ -31,6 +31,12 @@ export type FrameAction = {
31
31
  };
32
32
  export declare const MIN_FRAME_HEIGHT = 120;
33
33
  export declare const MAX_FRAME_HEIGHT = 1200;
34
+ /**
35
+ * A UI resource's preferred height (`preferredFrameSize[1]`, server-supplied) in px: only a plain
36
+ * pixel number (`480` or `480px`) counts, clamped to the frame limits; anything else is the
37
+ * minimum. Never interpolate the raw value into a style.
38
+ */
39
+ export declare function initialFrameHeight(value: string | undefined): number;
34
40
  /** Parse a message from the sandbox into a FrameAction, or null when it is not one. */
35
41
  export declare function parseFrameMessage(data: unknown): FrameAction | null;
36
42
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"ui-frame-protocol.d.ts","sourceRoot":"","sources":["../src/lib/ui-frame-protocol.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,eAAO,MAAM,cAAc,8BAA8B,CAAC;AAC1D,eAAO,MAAM,eAAe,oBAAoB,CAAC;AAEjD,MAAM,MAAM,WAAW,GACnB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAChC;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,GACpE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,GACpE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAC7B;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACnC;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,CAAC;AAEtB,eAAO,MAAM,gBAAgB,MAAM,CAAC;AACpC,eAAO,MAAM,gBAAgB,OAAO,CAAC;AAErC,uFAAuF;AACvF,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,OAAO,GAAG,WAAW,GAAG,IAAI,CA0CnE;AAQD;;;;;GAKG;AACH,wBAAgB,WAAW,CACzB,KAAK,EAAE,YAAY,EACnB,KAAK,EAAE,iBAAiB,GAAG,IAAI,EAC/B,aAAa,EAAE,MAAM,GACpB,OAAO,CAGT;AAED;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,MAAM,CAAC;AAEvC,+DAA+D;AAC/D,wBAAgB,eAAe,CAC7B,MAAM,EAAE,OAAO,CAAC,WAAW,EAAE;IAAE,IAAI,EAAE,QAAQ,GAAG,QAAQ,CAAA;CAAE,CAAC,GAC1D,MAAM,CAIR"}
1
+ {"version":3,"file":"ui-frame-protocol.d.ts","sourceRoot":"","sources":["../src/lib/ui-frame-protocol.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,eAAO,MAAM,cAAc,8BAA8B,CAAC;AAC1D,eAAO,MAAM,eAAe,oBAAoB,CAAC;AAEjD,MAAM,MAAM,WAAW,GACnB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAChC;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,GACpE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,GACpE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAC7B;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACnC;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,CAAC;AAEtB,eAAO,MAAM,gBAAgB,MAAM,CAAC;AACpC,eAAO,MAAM,gBAAgB,OAAO,CAAC;AAErC;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAIpE;AAED,uFAAuF;AACvF,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,OAAO,GAAG,WAAW,GAAG,IAAI,CA0CnE;AAQD;;;;;GAKG;AACH,wBAAgB,WAAW,CACzB,KAAK,EAAE,YAAY,EACnB,KAAK,EAAE,iBAAiB,GAAG,IAAI,EAC/B,aAAa,EAAE,MAAM,GACpB,OAAO,CAGT;AAED;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,MAAM,CAAC;AAEvC,+DAA+D;AAC/D,wBAAgB,eAAe,CAC7B,MAAM,EAAE,OAAO,CAAC,WAAW,EAAE;IAAE,IAAI,EAAE,QAAQ,GAAG,QAAQ,CAAA;CAAE,CAAC,GAC1D,MAAM,CAIR"}
@@ -8,6 +8,17 @@ export const UI_FRAME_READY = 'ui-lifecycle-iframe-ready';
8
8
  export const UI_FRAME_RENDER = 'agent-ui:render';
9
9
  export const MIN_FRAME_HEIGHT = 120;
10
10
  export const MAX_FRAME_HEIGHT = 1200;
11
+ /**
12
+ * A UI resource's preferred height (`preferredFrameSize[1]`, server-supplied) in px: only a plain
13
+ * pixel number (`480` or `480px`) counts, clamped to the frame limits; anything else is the
14
+ * minimum. Never interpolate the raw value into a style.
15
+ */
16
+ export function initialFrameHeight(value) {
17
+ const match = value === undefined ? null : /^(\d{1,4})(px)?$/.exec(value.trim());
18
+ if (!match)
19
+ return MIN_FRAME_HEIGHT;
20
+ return Math.min(MAX_FRAME_HEIGHT, Math.max(MIN_FRAME_HEIGHT, Number(match[1])));
21
+ }
11
22
  /** Parse a message from the sandbox into a FrameAction, or null when it is not one. */
12
23
  export function parseFrameMessage(data) {
13
24
  if (!data || typeof data !== 'object')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/agent-chat",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Svelte 5 chat UI for Classic Agent: session state, message list, composer, tool timeline, artifacts and MCP-UI sandbox frames. Consumed by the Classic App.",
@@ -14,11 +14,12 @@
14
14
  }
15
15
  },
16
16
  "files": [
17
- "dist"
17
+ "dist",
18
+ "!dist/**/*.test.*"
18
19
  ],
19
20
  "dependencies": {
20
- "@classic-homes/agent-client": "0.11.0",
21
- "@classic-homes/agent-contracts": "0.11.0",
21
+ "@classic-homes/agent-client": "0.13.0",
22
+ "@classic-homes/agent-contracts": "0.13.0",
22
23
  "dompurify": "3.4.17"
23
24
  },
24
25
  "peerDependencies": {
@@ -30,11 +31,11 @@
30
31
  "@sveltejs/package": "3.0.2",
31
32
  "@sveltejs/vite-plugin-svelte": "7.3.1",
32
33
  "@testing-library/svelte": "5.4.2",
33
- "@testing-library/user-event": "14.6.1",
34
- "axe-core": "4.13.0",
34
+ "@testing-library/user-event": "14.6.8",
35
+ "axe-core": "4.14.0",
35
36
  "jsdom": "30.1.3",
36
37
  "svelte": "5.57.3",
37
- "svelte-check": "4.7.7",
38
+ "svelte-check": "4.7.8",
38
39
  "vite": "8.3.5"
39
40
  },
40
41
  "license": "UNLICENSED",
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=artifact-pages.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"artifact-pages.test.d.ts","sourceRoot":"","sources":["../src/lib/artifact-pages.test.ts"],"names":[],"mappings":""}
@@ -1,102 +0,0 @@
1
- import { readdirSync, readFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { runInThisContext, Script } from 'node:vm';
4
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
- import { inlineScriptLiteral, MERMAID_LOAD_TIMEOUT_MS, MERMAID_MODULE_URL, mermaidPage, mermaidScript, } from './artifact-pages.js';
6
- const BREAKOUT = 'graph TD\n A["</script><script>alert(1)</script>"] --> B[<!-- x & y >]\u2028';
7
- function scripts(html) {
8
- const doc = new DOMParser().parseFromString(html, 'text/html');
9
- return [...doc.querySelectorAll('script')].map((s) => ({
10
- attrs: s
11
- .getAttributeNames()
12
- .map((n) => `${n}=${s.getAttribute(n)}`)
13
- .join(' '),
14
- body: s.textContent ?? '',
15
- }));
16
- }
17
- /** Run the page's init script against the current (jsdom) document with a stand-in `import()`. */
18
- function run(source, load) {
19
- document.body.innerHTML = '<div id="diagram" aria-busy="true"></div>';
20
- const body = mermaidScript(source).replace('import(', '__load(');
21
- const init = runInThisContext(`(function (__load) {\n${body}\n})`);
22
- init(load);
23
- }
24
- describe('mermaidPage', () => {
25
- it('initializes the renderer from a module script', () => {
26
- const found = scripts(mermaidPage('graph TD; A-->B'));
27
- expect(found).toHaveLength(1);
28
- expect(found[0].attrs).toBe('type=module');
29
- expect(found[0].body).toContain(`import(${JSON.stringify(MERMAID_MODULE_URL)})`);
30
- });
31
- it('emits a script that parses as a module and as a classic script', async () => {
32
- const body = mermaidScript(BREAKOUT);
33
- expect(() => new Script(body)).not.toThrow();
34
- // Module goal: strict mode, `import`/`await` reserved. vm.SourceTextModule is behind a
35
- // flag, so check the module-only constraints the classic parse cannot see.
36
- expect(() => new Script(`'use strict';${body}`)).not.toThrow();
37
- expect(body).not.toMatch(/^\s*import\s[^(]/m);
38
- expect(body).not.toMatch(/^\s*export\s/m);
39
- });
40
- it('cannot be broken out of by the diagram source', () => {
41
- const html = mermaidPage(BREAKOUT);
42
- const found = scripts(html);
43
- expect(found).toHaveLength(1);
44
- expect(html.match(/<\/script/gi)).toHaveLength(1);
45
- expect(found[0].body).not.toMatch(/<\/?script|<!--|\u2028|\u2029/i);
46
- expect(found[0].body).toContain(inlineScriptLiteral(BREAKOUT));
47
- // The literal round-trips to the exact source.
48
- expect(JSON.parse(inlineScriptLiteral(BREAKOUT))).toBe(BREAKOUT);
49
- expect(runInThisContext(`(${inlineScriptLiteral(BREAKOUT)})`)).toBe(BREAKOUT);
50
- });
51
- });
52
- describe('mermaid init script', () => {
53
- beforeEach(() => vi.useFakeTimers());
54
- afterEach(() => vi.useRealTimers());
55
- it('renders the SVG and passes the exact source', async () => {
56
- const render = vi.fn(async () => ({ svg: '<svg id="ok"></svg>' }));
57
- const initialize = vi.fn();
58
- const load = vi.fn(async () => ({ default: { initialize, render } }));
59
- run(BREAKOUT, load);
60
- await vi.runAllTimersAsync();
61
- expect(load).toHaveBeenCalledWith(MERMAID_MODULE_URL);
62
- expect(initialize).toHaveBeenCalledWith({
63
- startOnLoad: false,
64
- securityLevel: 'strict',
65
- suppressErrorRendering: true,
66
- });
67
- expect(render).toHaveBeenCalledWith('agent-mermaid-svg', BREAKOUT);
68
- expect(document.querySelector('#diagram svg#ok')).not.toBeNull();
69
- expect(document.querySelector('[role="alert"]')).toBeNull();
70
- expect(document.getElementById('diagram').hasAttribute('aria-busy')).toBe(false);
71
- });
72
- it('shows an error and the source when the renderer fails to load', async () => {
73
- run('graph TD; A-->B', () => Promise.reject(new TypeError('Failed to fetch module')));
74
- await vi.runAllTimersAsync();
75
- expect(document.querySelector('[role="alert"]')?.textContent).toBe('The diagram renderer could not be loaded: Failed to fetch module');
76
- expect(document.querySelector('#diagram pre')?.textContent).toBe('graph TD; A-->B');
77
- });
78
- it('shows an error when the diagram does not parse', async () => {
79
- const render = () => Promise.reject(new Error('Parse error on line 1'));
80
- run('graph ??', async () => ({ default: { initialize() { }, render } }));
81
- await vi.runAllTimersAsync();
82
- expect(document.querySelector('[role="alert"]')?.textContent).toBe('The diagram could not be rendered: Parse error on line 1');
83
- });
84
- it('shows an error when the renderer never loads', async () => {
85
- run('graph TD; A-->B', () => new Promise(() => { }));
86
- await vi.advanceTimersByTimeAsync(MERMAID_LOAD_TIMEOUT_MS);
87
- expect(document.querySelector('[role="alert"]')?.textContent).toBe('The diagram renderer did not load in time.');
88
- });
89
- });
90
- describe('svelte-package script-type stripping (R18)', () => {
91
- // svelte-package removes `type="…"` from every `<script` in a .svelte file, string literals
92
- // included. A .svelte file may hold only its own top-level <script> tags.
93
- it('no component builds <script> markup in a string', () => {
94
- const dir = join(import.meta.dirname, 'components');
95
- for (const file of readdirSync(dir).filter((f) => f.endsWith('.svelte'))) {
96
- const text = readFileSync(join(dir, file), 'utf8');
97
- const tags = text.match(/<script\b[^>]*>/g) ?? [];
98
- const topLevel = text.match(/^<script\b[^>]*>/gm) ?? [];
99
- expect(tags, file).toEqual(topLevel);
100
- }
101
- });
102
- });
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=a11y.svelte.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"a11y.svelte.test.d.ts","sourceRoot":"","sources":["../../src/lib/components/a11y.svelte.test.ts"],"names":[],"mappings":""}
@@ -1,183 +0,0 @@
1
- import { afterEach, describe, expect, it, vi } from 'vitest';
2
- import { cleanup, render, screen, waitFor } from '@testing-library/svelte';
3
- import axe from 'axe-core';
4
- import userEvent from '@testing-library/user-event';
5
- import AgentChat from './AgentChat.svelte';
6
- import ChatHeader from './ChatHeader.svelte';
7
- import ConversationSidebar from './ConversationSidebar.svelte';
8
- afterEach(() => {
9
- cleanup();
10
- document.body.removeAttribute('style');
11
- });
12
- /** axe in jsdom: no layout, so colour contrast is checked separately (tokens), not here. */
13
- async function violations(root = document.body) {
14
- const result = await axe.run(root, {
15
- rules: { 'color-contrast': { enabled: false }, region: { enabled: false } },
16
- });
17
- return result.violations.map((v) => `${v.id}: ${v.nodes.map((n) => n.html).join(' | ')}`);
18
- }
19
- const scope = { agent: 'classic-agent', runId: 'run-1' };
20
- function fakeClient() {
21
- return {
22
- fileUrl: (id) => `#${id}`,
23
- uploadFile: vi.fn(),
24
- cancel: vi.fn(),
25
- resumeTurn: vi.fn(),
26
- sendFeedback: vi.fn().mockResolvedValue(undefined),
27
- getConversation: vi.fn(),
28
- streamChat: vi.fn(async (req, opts) => {
29
- [
30
- { type: 'start', conversationId: 'c-1', mode: 'live' },
31
- { type: 'tool_call', callId: 't1', name: 'hotsheet/list_communities', argsPreview: '{}' },
32
- { type: 'tool_result', callId: 't1', status: 'ok', durationMs: 640, summary: '24 rows' },
33
- { type: 'delta', text: '| A | B |\n|---|---|\n| 1 | 2 |\n\n```ts\nconst a = 1;\n```\n' },
34
- {
35
- type: 'artifact',
36
- ...scope,
37
- artifactId: 'a1',
38
- op: 'open',
39
- mediaType: 'text/markdown',
40
- title: 'Memo',
41
- },
42
- { type: 'artifact', ...scope, artifactId: 'a1', op: 'delta', text: '# Memo' },
43
- { type: 'artifact', ...scope, artifactId: 'a1', op: 'close' },
44
- { type: 'done', finishReason: 'stop' },
45
- ].forEach((e, seq) => opts.onEvent({ requestId: req.requestId, seq, ...e }));
46
- return { kind: 'done' };
47
- }),
48
- };
49
- }
50
- const base = { sandboxOrigin: 'https://frame.test' };
51
- describe('accessibility (axe)', () => {
52
- it('has no violations on the welcome screen with the agent picker', async () => {
53
- render(AgentChat, {
54
- props: {
55
- ...base,
56
- client: fakeClient(),
57
- userName: 'Pat',
58
- suggestions: [{ label: 'Open lots', description: 'By community', icon: 'home' }],
59
- agents: [
60
- { key: 'classic-agent', name: 'Classic Agent' },
61
- { key: 'elevation-studio', name: 'Elevation Studio' },
62
- ],
63
- prompts: [{ id: 'p1', label: 'Weekly recap', text: 'Recap' }],
64
- },
65
- });
66
- expect(await violations()).toEqual([]);
67
- // With the saved-prompt menu open.
68
- await userEvent.type(screen.getByRole('textbox', { name: 'Message' }), '/');
69
- await screen.findByRole('listbox', { name: 'Saved prompts' });
70
- expect(await violations()).toEqual([]);
71
- });
72
- it('has no violations on a failed turn with Try again, and while editing', async () => {
73
- const user = userEvent.setup();
74
- const client = fakeClient();
75
- client.streamChat.mockImplementationOnce(async (req, opts) => {
76
- [
77
- { type: 'start', conversationId: 'c-1', mode: 'live' },
78
- { type: 'error', code: 'upstream_error', message: 'Model failed.', retryable: true },
79
- ].forEach((e, seq) => opts.onEvent({ requestId: req.requestId, seq, ...e }));
80
- return { kind: 'done' };
81
- });
82
- render(AgentChat, { props: { ...base, client: client } });
83
- await user.type(screen.getByRole('textbox', { name: 'Message' }), 'hi{Enter}');
84
- await screen.findByRole('button', { name: /Try again/ });
85
- expect(await violations()).toEqual([]);
86
- await user.click(screen.getByRole('button', { name: /Try again/ }));
87
- // The retried answer opens its artifact in a (modal) sheet; close it first.
88
- await screen.findByRole('dialog', { name: 'Memo' });
89
- await user.keyboard('{Escape}');
90
- await waitFor(() => expect(document.body.style.pointerEvents).not.toBe('none'));
91
- await user.click(await screen.findByRole('button', { name: 'Edit message' }));
92
- await screen.findByRole('textbox', { name: 'Edited message' });
93
- expect(await violations()).toEqual([]);
94
- });
95
- it('has no violations on a finished turn with tools, code, a table and an artifact', async () => {
96
- const user = userEvent.setup();
97
- render(AgentChat, { props: { ...base, client: fakeClient(), variant: 'panel' } });
98
- await user.type(screen.getByRole('textbox', { name: 'Message' }), 'hi{Enter}');
99
- await screen.findByRole('button', { name: /Open Memo|Memo/ });
100
- // The artifact opened in a sheet; close it to check the transcript itself.
101
- await user.keyboard('{Escape}');
102
- expect(await violations()).toEqual([]);
103
- });
104
- it('has no violations in the keyboard shortcuts dialog', async () => {
105
- const user = userEvent.setup();
106
- render(ChatHeader, {
107
- props: { title: 'x', shortcuts: [{ keys: ['Mod', 'J'], label: 'Open the agent panel' }] },
108
- });
109
- await user.click(screen.getByRole('button', { name: 'Conversation options' }));
110
- await user.click(await screen.findByRole('menuitem', { name: /Keyboard shortcuts/ }));
111
- await screen.findByRole('dialog', { name: 'Keyboard shortcuts' });
112
- expect(await violations()).toEqual([]);
113
- });
114
- it('has no violations in the conversation rail', async () => {
115
- const now = new Date().toISOString();
116
- render(ConversationSidebar, {
117
- props: {
118
- conversations: [
119
- {
120
- conversationId: 'c1',
121
- agent: 'classic-agent',
122
- title: 'Lot 12',
123
- createdAt: now,
124
- updatedAt: now,
125
- },
126
- {
127
- conversationId: 'c2',
128
- agent: 'classic-agent',
129
- title: 'Running',
130
- createdAt: now,
131
- updatedAt: now,
132
- activeRequestId: '11111111-1111-4111-8111-111111111111',
133
- },
134
- ],
135
- selectedId: 'c1',
136
- onrename: vi.fn(),
137
- ondelete: vi.fn(),
138
- },
139
- });
140
- expect(await violations()).toEqual([]);
141
- });
142
- });
143
- /** Answers "ok" with no artifact (an artifact opens a modal sheet, which takes focus). */
144
- function plainAnswers(client) {
145
- client.streamChat.mockImplementation(async (req, opts) => {
146
- [
147
- { type: 'start', conversationId: 'c-1', mode: 'live' },
148
- { type: 'delta', text: 'ok' },
149
- { type: 'done', finishReason: 'stop' },
150
- ].forEach((e, seq) => opts.onEvent({ requestId: req.requestId, seq, ...e }));
151
- return { kind: 'done' };
152
- });
153
- }
154
- describe('focus', () => {
155
- it('starts a new chat in the composer and returns there after a suggestion', async () => {
156
- const user = userEvent.setup();
157
- const client = fakeClient();
158
- plainAnswers(client);
159
- render(AgentChat, {
160
- props: { ...base, client: client, suggestions: [{ label: 'Open lots' }] },
161
- });
162
- const box = screen.getByRole('textbox', { name: 'Message' });
163
- await waitFor(() => expect(document.activeElement).toBe(box));
164
- await user.click(screen.getByRole('button', { name: 'Open lots' }));
165
- await waitFor(() => expect(document.activeElement).toBe(box));
166
- });
167
- it('returns to the Edit button when an edit is cancelled', async () => {
168
- const user = userEvent.setup();
169
- const client = fakeClient();
170
- plainAnswers(client);
171
- render(AgentChat, { props: { ...base, client: client } });
172
- await user.type(screen.getByRole('textbox', { name: 'Message' }), 'hi{Enter}');
173
- await user.click(await screen.findByRole('button', { name: 'Edit message' }));
174
- await user.keyboard('{Escape}');
175
- await waitFor(() => expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Edit message' })));
176
- });
177
- it('does not steal focus when opening an existing conversation', () => {
178
- const client = fakeClient();
179
- client.getConversation.mockReturnValue(new Promise(() => { }));
180
- render(AgentChat, { props: { ...base, client: client, conversationId: 'c-9' } });
181
- expect(document.activeElement).toBe(document.body);
182
- });
183
- });
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=agent-picker.svelte.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"agent-picker.svelte.test.d.ts","sourceRoot":"","sources":["../../src/lib/components/agent-picker.svelte.test.ts"],"names":[],"mappings":""}