@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
@@ -1,5 +1,8 @@
1
1
  import { ApiRequestError } from '@classic-homes/agent-client';
2
- import { applyEvent, newAssistantMessage, newUserMessage, latestTurn, turnForRetry, } from './reducer.js';
2
+ import { applyEvent, isContinuable, newAssistantMessage, newUserMessage, latestTurn, turnForRetry, } from './reducer.js';
3
+ /** Re-attach attempts after a stream ends without `done` (a dropped connection). */
4
+ const RESUME_ATTEMPTS = 2;
5
+ const DEFAULT_CANCEL_GRACE_MS = 3000;
3
6
  /**
4
7
  * Reactive conversation state (Svelte 5 runes class). Owns the stream lifecycle: one turn at a
5
8
  * time, cancel, resume after reload, history load. Rendering is left to the components.
@@ -15,16 +18,35 @@ export class ChatSession {
15
18
  agent = $state('classic-agent');
16
19
  loadError = $state(undefined);
17
20
  pendingFiles = $state([]);
21
+ /** The unsent composer text, kept here so it survives the view being remounted. */
22
+ draft = $state('');
18
23
  controller;
19
24
  /** The request being streamed; differs from the shown answer's id while continuing it. */
20
25
  streamRequestId;
26
+ /** Set once Stop was pressed for the running stream (the server cancel is on its way). */
27
+ cancelling = false;
28
+ cancelTimer;
29
+ /** Bumped by each load and reset, so a superseded load drops its result. */
30
+ loadToken = 0;
21
31
  options;
32
+ listeners = new Set();
22
33
  constructor(options) {
23
34
  this.options = options;
24
35
  this.conversationId = options.conversationId ?? null;
25
36
  this.mode = options.mode;
26
37
  this.agent = options.agent ?? 'classic-agent';
27
38
  }
39
+ /**
40
+ * Observe this session's stream events for as long as a view needs them. A session can
41
+ * outlive the component showing it (a host moving the chat between surfaces), so views
42
+ * subscribe instead of owning the `onEvent` option. Returns the unsubscribe.
43
+ */
44
+ subscribe(listener) {
45
+ this.listeners.add(listener);
46
+ return () => {
47
+ this.listeners.delete(listener);
48
+ };
49
+ }
28
50
  get streaming() {
29
51
  return this.status === 'streaming';
30
52
  }
@@ -32,12 +54,43 @@ export class ChatSession {
32
54
  const last = this.messages[this.messages.length - 1];
33
55
  return last?.role === 'assistant' ? last : undefined;
34
56
  }
57
+ /**
58
+ * Forget the current conversation (the host started a new chat): stops reading a running
59
+ * stream without cancelling it on the server (the turn finishes there and stays in its
60
+ * conversation) and clears the transcript.
61
+ */
62
+ reset() {
63
+ this.loadToken += 1;
64
+ this.detach();
65
+ this.status = 'idle';
66
+ this.messages = [];
67
+ this.conversationId = null;
68
+ this.loadedConversationId = null;
69
+ this.loadError = undefined;
70
+ this.pendingFiles = [];
71
+ }
72
+ /** Stop reading the running stream, if any; its `run` then leaves the session alone. */
73
+ detach() {
74
+ const controller = this.controller;
75
+ this.controller = undefined;
76
+ this.streamRequestId = undefined;
77
+ this.cancelling = false;
78
+ clearTimeout(this.cancelTimer);
79
+ this.cancelTimer = undefined;
80
+ controller?.abort();
81
+ }
35
82
  /** Load an existing conversation's transcript and re-attach to a running turn if any. */
36
83
  async load(conversationId) {
84
+ // Another conversation's stream must not keep writing into (or finishing) this one.
85
+ this.detach();
86
+ const token = ++this.loadToken;
37
87
  this.status = 'loading';
38
88
  this.loadError = undefined;
39
89
  try {
40
90
  const detail = await this.options.client.getConversation(conversationId);
91
+ // A newer load or a reset took over while this one waited.
92
+ if (token !== this.loadToken)
93
+ return;
41
94
  this.conversationId = conversationId;
42
95
  this.loadedConversationId = conversationId;
43
96
  this.messages = detail.messages.map((m) => m.role === 'user'
@@ -51,13 +104,9 @@ export class ChatSession {
51
104
  : {
52
105
  ...newAssistantMessage(m.id.replace(/^a-/, ''), m.createdAt),
53
106
  text: m.content.map((c) => c.text).join('\n'),
54
- // An answer that ended early keeps its Continue after a reload.
55
- status: m.finish === 'length'
56
- ? 'incomplete'
57
- : m.finish === 'cancelled'
58
- ? 'cancelled'
59
- : 'done',
60
- ...(m.finish ? { finishReason: m.finish } : {}),
107
+ // An answer that ended early keeps its Continue (or, after an error, its Try
108
+ // again) after a reload. A finish this client does not know reads as done.
109
+ ...loadedFinish(m.finish),
61
110
  ...(detail.feedback?.[m.id.replace(/^a-/, '')]
62
111
  ? { feedback: detail.feedback[m.id.replace(/^a-/, '')] }
63
112
  : {}),
@@ -69,6 +118,8 @@ export class ChatSession {
69
118
  await this.resume(active);
70
119
  }
71
120
  catch (err) {
121
+ if (token !== this.loadToken)
122
+ return;
72
123
  this.status = 'idle';
73
124
  this.loadError = err instanceof Error ? err.message : 'Could not load the conversation';
74
125
  }
@@ -95,10 +146,15 @@ export class ChatSession {
95
146
  ...(this.options.clientKind ? { client: this.options.clientKind } : {}),
96
147
  ...(options.replaces ? { replaces: options.replaces } : {}),
97
148
  };
149
+ const answer = newAssistantMessage(requestId);
150
+ // Until the runtime accepts the turn (`start`), remember which stored turn it replaces, so
151
+ // retrying a turn that never started still replaces the original (see `replacesFor`).
152
+ if (options.replaces)
153
+ answer.replaces = options.replaces;
98
154
  this.messages = [
99
155
  ...this.messages,
100
156
  newUserMessage(requestId, message, { attachments: files, context }),
101
- newAssistantMessage(requestId),
157
+ answer,
102
158
  ];
103
159
  await this.run(requestId, (signal, onEvent) => this.options.client.streamChat(request, { signal, onEvent }));
104
160
  }
@@ -112,8 +168,17 @@ export class ChatSession {
112
168
  const turn = turnForRetry(this.messages, requestId);
113
169
  if (!turn)
114
170
  return;
171
+ const replaces = this.replacesFor(requestId);
115
172
  this.messages = turn.rest;
116
- await this.send(turn.user.text, { files: turn.user.attachments, replaces: requestId });
173
+ await this.send(turn.user.text, { files: turn.user.attachments, replaces });
174
+ }
175
+ /**
176
+ * The stored turn a resend of `requestId` replaces: the request itself once the runtime
177
+ * accepted it, else the turn that request was meant to replace (the runtime never stored it).
178
+ */
179
+ replacesFor(requestId) {
180
+ const answer = this.messages.find((m) => m.role === 'assistant' && m.requestId === requestId);
181
+ return answer?.replaces ?? requestId;
117
182
  }
118
183
  /** Ask again for a new answer to the latest question, replacing the current one. */
119
184
  async regenerate(requestId) {
@@ -122,8 +187,9 @@ export class ChatSession {
122
187
  const turn = latestTurn(this.messages, requestId);
123
188
  if (!turn)
124
189
  return;
190
+ const replaces = this.replacesFor(requestId);
125
191
  this.messages = turn.rest;
126
- await this.send(turn.user.text, { files: turn.user.attachments, replaces: requestId });
192
+ await this.send(turn.user.text, { files: turn.user.attachments, replaces });
127
193
  }
128
194
  /** Change the latest question and answer it again, replacing that turn. */
129
195
  async edit(requestId, text) {
@@ -132,8 +198,9 @@ export class ChatSession {
132
198
  const turn = latestTurn(this.messages, requestId);
133
199
  if (!turn)
134
200
  return;
201
+ const replaces = this.replacesFor(requestId);
135
202
  this.messages = turn.rest;
136
- await this.send(text, { files: turn.user.attachments, replaces: requestId });
203
+ await this.send(text, { files: turn.user.attachments, replaces });
137
204
  }
138
205
  /**
139
206
  * Carry on the latest answer where it was cut short or stopped. The runtime asks the agent to
@@ -145,10 +212,9 @@ export class ChatSession {
145
212
  if (!conversationId || this.status !== 'idle')
146
213
  return;
147
214
  const last = this.current;
148
- if (last?.requestId !== requestId ||
149
- (last.status !== 'incomplete' && last.status !== 'cancelled') ||
150
- !last.text)
215
+ if (last?.requestId !== requestId || !isContinuable(last))
151
216
  return;
217
+ const previous = { status: last.status, finishReason: last.finishReason };
152
218
  const request = {
153
219
  requestId: crypto.randomUUID(),
154
220
  conversationId,
@@ -163,7 +229,7 @@ export class ChatSession {
163
229
  delete next.error;
164
230
  return next;
165
231
  });
166
- await this.run(requestId, (signal, onEvent) => this.options.client.streamChat(request, { signal, onEvent }), request.requestId);
232
+ await this.run(requestId, (signal, onEvent) => this.options.client.streamChat(request, { signal, onEvent }), request.requestId, previous);
167
233
  }
168
234
  /** Re-attach to a turn after reload: replays events after the last seq we hold (none here). */
169
235
  async resume(requestId) {
@@ -207,20 +273,40 @@ export class ChatSession {
207
273
  throw err;
208
274
  }
209
275
  }
276
+ /**
277
+ * Stop the running answer. The runtime is asked to cancel and the stream is read on until its
278
+ * `done`, so text produced before the cancel landed still shows (and a later Continue appends
279
+ * after it); after `cancelGraceMs` the stream is dropped locally. The answer shows as stopped
280
+ * at once.
281
+ */
210
282
  cancel() {
211
283
  const current = this.current;
212
- if (!this.controller || !current)
284
+ const controller = this.controller;
285
+ if (!controller || !current || this.cancelling)
213
286
  return;
214
- this.controller.abort();
215
- void this.options.client.cancel(this.streamRequestId ?? current.requestId);
287
+ this.cancelling = true;
288
+ const answerId = current.requestId;
289
+ this.update(answerId, (m) => m.status === 'streaming' ? { ...m, status: 'cancelled', finishReason: 'cancelled' } : m);
290
+ void this.options.client.cancel(this.streamRequestId ?? answerId);
291
+ this.cancelTimer = setTimeout(() => controller.abort(), this.options.cancelGraceMs ?? DEFAULT_CANCEL_GRACE_MS);
216
292
  }
217
293
  async run(requestId, start,
218
294
  /** The request streamed, when it is not `requestId` (a continuation of that answer). */
219
- streamRequestId = requestId) {
295
+ streamRequestId = requestId,
296
+ /** A continuation: the answer's state before it, restored when the continuation fails. */
297
+ previous) {
298
+ this.detach();
220
299
  this.status = 'streaming';
221
- this.controller = new AbortController();
300
+ const controller = new AbortController();
301
+ this.controller = controller;
222
302
  this.streamRequestId = streamRequestId;
303
+ // A reset, a load or a newer turn replaced this stream: leave the session alone.
304
+ const stale = () => this.controller !== controller;
305
+ let lastSeq = -1;
223
306
  const onEvent = (event) => {
307
+ if (stale())
308
+ return;
309
+ lastSeq = event.seq;
224
310
  if (event.type === 'start') {
225
311
  this.mode = event.mode;
226
312
  if (this.conversationId !== event.conversationId) {
@@ -233,15 +319,48 @@ export class ChatSession {
233
319
  this.options.onToolResult?.(this.toolName(requestId, event.callId), event.status);
234
320
  this.update(requestId, (m) => applyEvent(m, event));
235
321
  this.options.onEvent?.(event);
322
+ for (const listener of [...this.listeners])
323
+ listener(event);
236
324
  };
237
325
  try {
238
- const outcome = await start(this.controller.signal, onEvent);
326
+ let outcome = await start(controller.signal, onEvent);
327
+ // The connection dropped before `done`: the turn may still be running (or have just
328
+ // finished) on the server, so re-attach after the last event seen. Continue is not the
329
+ // answer here; the runtime refuses it while the turn runs.
330
+ for (let attempt = 0; outcome.kind === 'ended' && attempt < RESUME_ATTEMPTS && !stale() && !this.cancelling; attempt += 1) {
331
+ const conversationId = this.conversationId;
332
+ if (!conversationId)
333
+ break;
334
+ try {
335
+ outcome = await this.options.client.resumeTurn(conversationId, streamRequestId, lastSeq, {
336
+ signal: controller.signal,
337
+ onEvent,
338
+ });
339
+ }
340
+ catch {
341
+ // Counts as an attempt; the outcome stays `ended`.
342
+ }
343
+ }
344
+ if (stale())
345
+ return;
239
346
  if (outcome.kind === 'aborted')
240
347
  this.update(requestId, (m) => m.status === 'streaming' ? { ...m, status: 'cancelled', finishReason: 'cancelled' } : m);
241
348
  else if (outcome.kind === 'ended')
242
- this.update(requestId, (m) => m.status === 'streaming' ? { ...m, status: 'incomplete' } : m);
349
+ this.update(requestId, (m) => m.status === 'streaming'
350
+ ? {
351
+ ...m,
352
+ status: 'error',
353
+ error: {
354
+ code: 'network',
355
+ message: 'The connection was lost before the answer finished.',
356
+ retryable: true,
357
+ },
358
+ }
359
+ : m);
243
360
  }
244
361
  catch (err) {
362
+ if (stale())
363
+ return;
245
364
  const code = err instanceof ApiRequestError ? err.code : 'network';
246
365
  const text = err instanceof Error ? err.message : 'The request failed';
247
366
  this.update(requestId, (m) => ({
@@ -255,6 +374,28 @@ export class ChatSession {
255
374
  }));
256
375
  }
257
376
  finally {
377
+ if (!stale())
378
+ this.finish(requestId, previous);
379
+ }
380
+ }
381
+ /** The turn's stream is over: restore a failed continuation, go idle, tell the host. */
382
+ finish(requestId, previous) {
383
+ // A failed continuation keeps the text so far continuable; the error shows inline.
384
+ if (previous)
385
+ this.update(requestId, (m) => {
386
+ if (m.status !== 'error')
387
+ return m;
388
+ const next = { ...m, status: previous.status };
389
+ if (previous.finishReason)
390
+ next.finishReason = previous.finishReason;
391
+ else
392
+ delete next.finishReason;
393
+ return next;
394
+ });
395
+ {
396
+ clearTimeout(this.cancelTimer);
397
+ this.cancelTimer = undefined;
398
+ this.cancelling = false;
258
399
  this.controller = undefined;
259
400
  this.streamRequestId = undefined;
260
401
  this.status = 'idle';
@@ -274,6 +415,20 @@ export class ChatSession {
274
415
  this.messages = this.messages.map((m) => m.role === 'assistant' && m.requestId === requestId ? fn(m) : m);
275
416
  }
276
417
  }
418
+ /** Status for a stored answer's `finish` (absent: it finished normally). */
419
+ function loadedFinish(finish) {
420
+ if (finish === 'length')
421
+ return { status: 'incomplete', finishReason: 'length' };
422
+ if (finish === 'cancelled')
423
+ return { status: 'cancelled', finishReason: 'cancelled' };
424
+ if (finish === 'error')
425
+ return {
426
+ status: 'error',
427
+ finishReason: 'error',
428
+ error: { code: 'upstream_error', message: 'The answer did not finish.', retryable: true },
429
+ };
430
+ return { status: 'done' };
431
+ }
277
432
  export function createChatSession(options) {
278
433
  return new ChatSession(options);
279
434
  }
@@ -8,4 +8,11 @@ export interface ChatShortcut {
8
8
  }
9
9
  /** What the chat itself handles, in the composer. Hosts add their own (e.g. a panel toggle). */
10
10
  export declare const CHAT_SHORTCUTS: readonly ChatShortcut[];
11
+ /**
12
+ * The chat's shortcuts for the dialog: without saved prompts (`AgentChat`'s `prompts`) the `/`
13
+ * menu is off, so its entry is left out.
14
+ */
15
+ export declare function chatShortcuts(options?: {
16
+ savedPrompts?: boolean;
17
+ }): ChatShortcut[];
11
18
  //# sourceMappingURL=shortcuts.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"shortcuts.d.ts","sourceRoot":"","sources":["../src/lib/shortcuts.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACf;AAED,gGAAgG;AAChG,eAAO,MAAM,cAAc,EAAE,SAAS,YAAY,EAMjD,CAAC"}
1
+ {"version":3,"file":"shortcuts.d.ts","sourceRoot":"","sources":["../src/lib/shortcuts.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACf;AAED,gGAAgG;AAChG,eAAO,MAAM,cAAc,EAAE,SAAS,YAAY,EAMjD,CAAC;AAEF;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,GAAE;IAAE,YAAY,CAAC,EAAE,OAAO,CAAA;CAAO,GAAG,YAAY,EAAE,CAItF"}
package/dist/shortcuts.js CHANGED
@@ -6,3 +6,12 @@ export const CHAT_SHORTCUTS = [
6
6
  { keys: ['/'], label: 'Insert a saved prompt' },
7
7
  { keys: ['Esc'], label: 'Cancel editing a message' },
8
8
  ];
9
+ /**
10
+ * The chat's shortcuts for the dialog: without saved prompts (`AgentChat`'s `prompts`) the `/`
11
+ * menu is off, so its entry is left out.
12
+ */
13
+ export function chatShortcuts(options = {}) {
14
+ return options.savedPrompts === false
15
+ ? CHAT_SHORTCUTS.filter((s) => s.label !== 'Insert a saved prompt')
16
+ : [...CHAT_SHORTCUTS];
17
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Geometry for the detached chat: a floating launcher button that snaps to a viewport corner and
3
+ * a floating window the user can move and resize. Pure functions in viewport (CSS px,
4
+ * `position: fixed`) coordinates so they can be tested without layout.
5
+ *
6
+ * The drag threshold and the separate launcher/window positions follow the customer chat widget
7
+ * (Classic-Homes/chat, `packages/widget`), rewritten for this package.
8
+ */
9
+ export interface Point {
10
+ x: number;
11
+ y: number;
12
+ }
13
+ export interface Size {
14
+ width: number;
15
+ height: number;
16
+ }
17
+ export interface Rect extends Point, Size {
18
+ }
19
+ export type Corner = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
20
+ export declare const CORNERS: readonly Corner[];
21
+ /** Compass edges a resize handle moves. */
22
+ export type ResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
23
+ export declare const RESIZE_DIRECTIONS: readonly ResizeDirection[];
24
+ export declare const FLOAT_LIMITS: {
25
+ readonly minWidth: 340;
26
+ readonly minHeight: 420;
27
+ readonly maxWidth: 900;
28
+ readonly maxHeight: 1000;
29
+ /** Gap kept between the window and the viewport edge. */
30
+ readonly margin: 12;
31
+ };
32
+ export declare const DEFAULT_FLOAT_SIZE: Size;
33
+ export declare const LAUNCHER_SIZE = 56;
34
+ export declare const LAUNCHER_MARGIN = 20;
35
+ /** Pointer travel (px) before a press on the launcher or the window header counts as a drag. */
36
+ export declare const DRAG_THRESHOLD = 3;
37
+ /** Releasing a dragged window with the pointer this close to the right edge docks it. */
38
+ export declare const DOCK_THRESHOLD = 24;
39
+ /** Arrow-key step for moving and resizing the window; Alt makes it fine. */
40
+ export declare const KEY_STEP = 24;
41
+ /** Size within the limits and never larger than the viewport allows. */
42
+ export declare function clampSize(size: Size, viewport: Size): Size;
43
+ /** The rect resized to the limits and moved fully inside the viewport. */
44
+ export declare function clampRect(rect: Rect, viewport: Size): Rect;
45
+ /** Where a new window opens: beside the launcher's corner, clear of the launcher itself. */
46
+ export declare function defaultRect(viewport: Size, corner: Corner, size?: Size): Rect;
47
+ /** Top-left point of the launcher button when it sits in `corner`. */
48
+ export declare function launcherPoint(corner: Corner, viewport: Size): Point;
49
+ /** Keep a freely placed launcher button fully inside the viewport. */
50
+ export declare function clampLauncher(point: Point, viewport: Size): Point;
51
+ /** The corner a dropped launcher snaps to. */
52
+ export declare function nearestCorner(point: Point, viewport: Size): Corner;
53
+ /**
54
+ * The rect after dragging a resize handle by `(dx, dy)`. The opposite edge stays put, also when
55
+ * a limit stops the dragged edge.
56
+ */
57
+ export declare function resizeRect(rect: Rect, direction: ResizeDirection, dx: number, dy: number, viewport: Size): Rect;
58
+ /** Was a dragged window released against the right edge (the dock side)? */
59
+ export declare function isDockDrop(pointer: Point, viewport: Size): boolean;
60
+ //# sourceMappingURL=surface-geometry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"surface-geometry.d.ts","sourceRoot":"","sources":["../src/lib/surface-geometry.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,WAAW,KAAK;IACpB,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACX;AACD,MAAM,WAAW,IAAI;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AACD,MAAM,WAAW,IAAK,SAAQ,KAAK,EAAE,IAAI;CAAG;AAE5C,MAAM,MAAM,MAAM,GAAG,cAAc,GAAG,aAAa,GAAG,WAAW,GAAG,UAAU,CAAC;AAC/E,eAAO,MAAM,OAAO,EAAE,SAAS,MAAM,EAA6D,CAAC;AAEnG,2CAA2C;AAC3C,MAAM,MAAM,eAAe,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAChF,eAAO,MAAM,iBAAiB,EAAE,SAAS,eAAe,EASvD,CAAC;AAEF,eAAO,MAAM,YAAY;;;;;IAKvB,yDAAyD;;CAEjD,CAAC;AACX,eAAO,MAAM,kBAAkB,EAAE,IAAkC,CAAC;AACpE,eAAO,MAAM,aAAa,KAAK,CAAC;AAChC,eAAO,MAAM,eAAe,KAAK,CAAC;AAClC,gGAAgG;AAChG,eAAO,MAAM,cAAc,IAAI,CAAC;AAChC,yFAAyF;AACzF,eAAO,MAAM,cAAc,KAAK,CAAC;AACjC,4EAA4E;AAC5E,eAAO,MAAM,QAAQ,KAAK,CAAC;AAK3B,wEAAwE;AACxE,wBAAgB,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,GAAG,IAAI,CAS1D;AAED,0EAA0E;AAC1E,wBAAgB,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,GAAG,IAAI,CAQ1D;AAED,4FAA4F;AAC5F,wBAAgB,WAAW,CAAC,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,GAAE,IAAyB,GAAG,IAAI,CAmBjG;AAED,sEAAsE;AACtE,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,GAAG,KAAK,CAMnE;AAED,sEAAsE;AACtE,wBAAgB,aAAa,CAAC,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,GAAG,KAAK,CAIjE;AAED,8CAA8C;AAC9C,wBAAgB,aAAa,CAAC,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,GAAG,MAAM,CAIlE;AAED;;;GAGG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,IAAI,EACV,SAAS,EAAE,eAAe,EAC1B,EAAE,EAAE,MAAM,EACV,EAAE,EAAE,MAAM,EACV,QAAQ,EAAE,IAAI,GACb,IAAI,CAaN;AAED,4EAA4E;AAC5E,wBAAgB,UAAU,CAAC,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,GAAG,OAAO,CAElE"}
@@ -0,0 +1,112 @@
1
+ export const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'];
2
+ export const RESIZE_DIRECTIONS = [
3
+ 'n',
4
+ 's',
5
+ 'e',
6
+ 'w',
7
+ 'ne',
8
+ 'nw',
9
+ 'se',
10
+ 'sw',
11
+ ];
12
+ export const FLOAT_LIMITS = {
13
+ minWidth: 340,
14
+ minHeight: 420,
15
+ maxWidth: 900,
16
+ maxHeight: 1000,
17
+ /** Gap kept between the window and the viewport edge. */
18
+ margin: 12,
19
+ };
20
+ export const DEFAULT_FLOAT_SIZE = { width: 420, height: 640 };
21
+ export const LAUNCHER_SIZE = 56;
22
+ export const LAUNCHER_MARGIN = 20;
23
+ /** Pointer travel (px) before a press on the launcher or the window header counts as a drag. */
24
+ export const DRAG_THRESHOLD = 3;
25
+ /** Releasing a dragged window with the pointer this close to the right edge docks it. */
26
+ export const DOCK_THRESHOLD = 24;
27
+ /** Arrow-key step for moving and resizing the window; Alt makes it fine. */
28
+ export const KEY_STEP = 24;
29
+ const between = (value, min, max) => Math.min(Math.max(value, min), Math.max(min, max));
30
+ /** Size within the limits and never larger than the viewport allows. */
31
+ export function clampSize(size, viewport) {
32
+ const { minWidth, minHeight, maxWidth, maxHeight, margin } = FLOAT_LIMITS;
33
+ const roomX = viewport.width - margin * 2;
34
+ const roomY = viewport.height - margin * 2;
35
+ return {
36
+ // A viewport smaller than the minimum wins: the window must stay fully visible.
37
+ width: Math.round(Math.min(between(size.width, minWidth, maxWidth), Math.max(roomX, 0))),
38
+ height: Math.round(Math.min(between(size.height, minHeight, maxHeight), Math.max(roomY, 0))),
39
+ };
40
+ }
41
+ /** The rect resized to the limits and moved fully inside the viewport. */
42
+ export function clampRect(rect, viewport) {
43
+ const size = clampSize(rect, viewport);
44
+ const { margin } = FLOAT_LIMITS;
45
+ return {
46
+ ...size,
47
+ x: Math.round(between(rect.x, margin, viewport.width - size.width - margin)),
48
+ y: Math.round(between(rect.y, margin, viewport.height - size.height - margin)),
49
+ };
50
+ }
51
+ /** Where a new window opens: beside the launcher's corner, clear of the launcher itself. */
52
+ export function defaultRect(viewport, corner, size = DEFAULT_FLOAT_SIZE) {
53
+ const clear = LAUNCHER_MARGIN + LAUNCHER_SIZE + FLOAT_LIMITS.margin;
54
+ const fitted = clampSize({
55
+ width: size.width,
56
+ height: Math.min(size.height, viewport.height - clear - FLOAT_LIMITS.margin),
57
+ }, viewport);
58
+ return clampRect({
59
+ ...fitted,
60
+ x: corner.endsWith('left')
61
+ ? LAUNCHER_MARGIN
62
+ : viewport.width - fitted.width - LAUNCHER_MARGIN,
63
+ y: corner.startsWith('top') ? clear : viewport.height - fitted.height - clear,
64
+ }, viewport);
65
+ }
66
+ /** Top-left point of the launcher button when it sits in `corner`. */
67
+ export function launcherPoint(corner, viewport) {
68
+ const far = (extent) => Math.max(0, extent - LAUNCHER_SIZE - LAUNCHER_MARGIN);
69
+ return {
70
+ x: corner.endsWith('left') ? LAUNCHER_MARGIN : far(viewport.width),
71
+ y: corner.startsWith('top') ? LAUNCHER_MARGIN : far(viewport.height),
72
+ };
73
+ }
74
+ /** Keep a freely placed launcher button fully inside the viewport. */
75
+ export function clampLauncher(point, viewport) {
76
+ const within = (value, extent) => Math.min(Math.max(0, value), Math.max(0, extent - LAUNCHER_SIZE));
77
+ return { x: within(point.x, viewport.width), y: within(point.y, viewport.height) };
78
+ }
79
+ /** The corner a dropped launcher snaps to. */
80
+ export function nearestCorner(point, viewport) {
81
+ const vertical = point.y < viewport.height / 2 ? 'top' : 'bottom';
82
+ const horizontal = point.x < viewport.width / 2 ? 'left' : 'right';
83
+ return `${vertical}-${horizontal}`;
84
+ }
85
+ /**
86
+ * The rect after dragging a resize handle by `(dx, dy)`. The opposite edge stays put, also when
87
+ * a limit stops the dragged edge.
88
+ */
89
+ export function resizeRect(rect, direction, dx, dy, viewport) {
90
+ const { margin } = FLOAT_LIMITS;
91
+ let { x, y, width, height } = rect;
92
+ const right = x + width;
93
+ const bottom = y + height;
94
+ if (direction.includes('e'))
95
+ width = Math.min(width + dx, viewport.width - margin - x);
96
+ if (direction.includes('s'))
97
+ height = Math.min(height + dy, viewport.height - margin - y);
98
+ if (direction.includes('w'))
99
+ width = Math.min(width - dx, right - margin);
100
+ if (direction.includes('n'))
101
+ height = Math.min(height - dy, bottom - margin);
102
+ ({ width, height } = clampSize({ width, height }, viewport));
103
+ if (direction.includes('w'))
104
+ x = right - width;
105
+ if (direction.includes('n'))
106
+ y = bottom - height;
107
+ return clampRect({ x, y, width, height }, viewport);
108
+ }
109
+ /** Was a dragged window released against the right edge (the dock side)? */
110
+ export function isDockDrop(pointer, viewport) {
111
+ return pointer.x >= viewport.width - DOCK_THRESHOLD;
112
+ }
@@ -0,0 +1,79 @@
1
+ import { type Corner, type Point, type Rect, type ResizeDirection, type Size } from './surface-geometry.js';
2
+ /**
3
+ * How the chat is presented over a host page: collapsed to the launcher button, a floating
4
+ * window, or docked to the side. The full page is a host route, not a mode here; a host on that
5
+ * route simply renders none of these.
6
+ */
7
+ export type SurfaceMode = 'launcher' | 'float' | 'dock';
8
+ export type OpenSurfaceMode = Exclude<SurfaceMode, 'launcher'>;
9
+ /** What the collapsed launcher shows about the chat behind it. */
10
+ export type LauncherStatus = 'idle' | 'streaming' | 'unread' | 'error';
11
+ export interface ChatSurfaceOptions {
12
+ /** Where layout is remembered, e.g. `classic-agent:surface:<userId>`. Not persisted without it. */
13
+ storageKey?: string;
14
+ /** Defaults to `localStorage` when available. */
15
+ storage?: Pick<Storage, 'getItem' | 'setItem'>;
16
+ /** Defaults to the window's inner size. */
17
+ viewport?: () => Size;
18
+ /** What the launcher opens the first time. */
19
+ defaultOpen?: OpenSurfaceMode;
20
+ corner?: Corner;
21
+ }
22
+ /**
23
+ * Reactive state of the detached chat (Svelte 5 runes class): which presentation is showing,
24
+ * where the launcher sits, the floating window's geometry and whether an answer arrived unseen.
25
+ * Layout survives reloads through `storageKey`; a saved window that no longer fits the viewport
26
+ * is pulled back inside.
27
+ */
28
+ export declare class ChatSurface {
29
+ mode: SurfaceMode;
30
+ /** The open presentation the launcher returns to. */
31
+ lastOpen: OpenSurfaceMode;
32
+ corner: Corner;
33
+ /**
34
+ * Where the user dropped the launcher (its top-left), or null while it sits in `corner`.
35
+ * Separate from the window's place: moving one never moves the other.
36
+ */
37
+ launcher: Point | null;
38
+ /** An answer finished while the chat was collapsed. Cleared on open. */
39
+ unread: boolean;
40
+ /** True while the user drags or resizes the window. */
41
+ moving: boolean;
42
+ private saved;
43
+ private readonly options;
44
+ constructor(options?: ChatSurfaceOptions);
45
+ get isOpen(): boolean;
46
+ /** The floating window's rect; a default beside the launcher until the user moves it. */
47
+ get rect(): Rect;
48
+ open(mode?: OpenSurfaceMode): void;
49
+ minimize(): void;
50
+ toggle(): void;
51
+ dock(): void;
52
+ float(): void;
53
+ /** A turn ended; flag it when the user cannot see the answer. */
54
+ notifyTurnEnd(): void;
55
+ setCorner(corner: Corner): void;
56
+ /**
57
+ * The user dropped the launcher at `point` (top-left). It stays there; `corner` follows the
58
+ * nearest one, which is where a window that was never moved opens.
59
+ */
60
+ setLauncher(point: Point): void;
61
+ /** Move the window by a pointer or key delta. Call `commit` when the gesture ends. */
62
+ moveBy(dx: number, dy: number): void;
63
+ resizeBy(direction: ResizeDirection, dx: number, dy: number): void;
64
+ /** End of a move or resize: remember the result. */
65
+ commit(): void;
66
+ /** Forget the user's geometry and return the window to its default place. */
67
+ resetRect(): void;
68
+ /**
69
+ * Keep the window inside the viewport as it changes size. Returns the cleanup; call from an
70
+ * effect while the floating window is mounted.
71
+ */
72
+ attach(target?: Pick<Window, 'addEventListener' | 'removeEventListener'>): () => void;
73
+ private viewport;
74
+ private storage;
75
+ private restore;
76
+ private persist;
77
+ }
78
+ export declare function createChatSurface(options?: ChatSurfaceOptions): ChatSurface;
79
+ //# sourceMappingURL=surface.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"surface.svelte.d.ts","sourceRoot":"","sources":["../src/lib/surface.svelte.ts"],"names":[],"mappings":"AAAA,OAAO,EAQL,KAAK,MAAM,EACX,KAAK,KAAK,EACV,KAAK,IAAI,EACT,KAAK,eAAe,EACpB,KAAK,IAAI,EACV,MAAM,uBAAuB,CAAC;AAE/B;;;;GAIG;AACH,MAAM,MAAM,WAAW,GAAG,UAAU,GAAG,OAAO,GAAG,MAAM,CAAC;AACxD,MAAM,MAAM,eAAe,GAAG,OAAO,CAAC,WAAW,EAAE,UAAU,CAAC,CAAC;AAE/D,kEAAkE;AAClE,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,WAAW,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEvE,MAAM,WAAW,kBAAkB;IACjC,mGAAmG;IACnG,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,iDAAiD;IACjD,OAAO,CAAC,EAAE,IAAI,CAAC,OAAO,EAAE,SAAS,GAAG,SAAS,CAAC,CAAC;IAC/C,2CAA2C;IAC3C,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,8CAA8C;IAC9C,WAAW,CAAC,EAAE,eAAe,CAAC;IAC9B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAuBD;;;;;GAKG;AACH,qBAAa,WAAW;IACtB,IAAI,cAAmC;IACvC,qDAAqD;IACrD,QAAQ,kBAAoC;IAC5C,MAAM,SAAkC;IACxC;;;OAGG;IACH,QAAQ,eAA8B;IACtC,wEAAwE;IACxE,MAAM,UAAiB;IACvB,uDAAuD;IACvD,MAAM,UAAiB;IACvB,OAAO,CAAC,KAAK,CAA6B;IAC1C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAqB;gBAEjC,OAAO,GAAE,kBAAuB;IAO5C,IAAI,MAAM,IAAI,OAAO,CAEpB;IAED,yFAAyF;IACzF,IAAI,IAAI,IAAI,IAAI,CAGf;IAED,IAAI,CAAC,IAAI,GAAE,eAA+B,GAAG,IAAI;IAMjD,QAAQ,IAAI,IAAI;IAIhB,MAAM,IAAI,IAAI;IAId,IAAI,IAAI,IAAI;IAGZ,KAAK,IAAI,IAAI;IAIb,iEAAiE;IACjE,aAAa,IAAI,IAAI;IAIrB,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IAK/B;;;OAGG;IACH,WAAW,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI;IAU/B,sFAAsF;IACtF,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,IAAI;IAIpC,QAAQ,CAAC,SAAS,EAAE,eAAe,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,IAAI;IAGlE,oDAAoD;IACpD,MAAM,IAAI,IAAI;IAId,6EAA6E;IAC7E,SAAS,IAAI,IAAI;IAKjB;;;OAGG;IACH,MAAM,CAAC,MAAM,GAAE,IAAI,CAAC,MAAM,EAAE,kBAAkB,GAAG,qBAAqB,CAAU,GAAG,MAAM,IAAI;IAS7F,OAAO,CAAC,QAAQ;IAMhB,OAAO,CAAC,OAAO;IAUf,OAAO,CAAC,OAAO;IAkBf,OAAO,CAAC,OAAO;CAehB;AAED,wBAAgB,iBAAiB,CAAC,OAAO,GAAE,kBAAuB,GAAG,WAAW,CAE/E"}