@classic-homes/agent-chat 0.11.0 → 0.12.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 (114) hide show
  1. package/README.md +57 -2
  2. package/dist/components/AgentChat.svelte +58 -22
  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 +14 -5
  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 +157 -0
  13. package/dist/components/ChatLauncher.svelte.d.ts +23 -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 +239 -0
  18. package/dist/components/FloatingWindow.svelte.d.ts +26 -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 +4 -0
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +4 -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 +38 -0
  49. package/dist/session.svelte.d.ts.map +1 -1
  50. package/dist/session.svelte.js +176 -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 +56 -0
  55. package/dist/surface-geometry.d.ts.map +1 -0
  56. package/dist/surface-geometry.js +99 -0
  57. package/dist/surface.svelte.d.ts +69 -0
  58. package/dist/surface.svelte.d.ts.map +1 -0
  59. package/dist/surface.svelte.js +156 -0
  60. package/dist/ui-frame-protocol.d.ts +6 -0
  61. package/dist/ui-frame-protocol.d.ts.map +1 -1
  62. package/dist/ui-frame-protocol.js +11 -0
  63. package/package.json +8 -7
  64. package/dist/artifact-pages.test.d.ts +0 -2
  65. package/dist/artifact-pages.test.d.ts.map +0 -1
  66. package/dist/artifact-pages.test.js +0 -102
  67. package/dist/components/a11y.svelte.test.d.ts +0 -2
  68. package/dist/components/a11y.svelte.test.d.ts.map +0 -1
  69. package/dist/components/a11y.svelte.test.js +0 -183
  70. package/dist/components/agent-picker.svelte.test.d.ts +0 -2
  71. package/dist/components/agent-picker.svelte.test.d.ts.map +0 -1
  72. package/dist/components/agent-picker.svelte.test.js +0 -84
  73. package/dist/components/composer.svelte.test.d.ts +0 -2
  74. package/dist/components/composer.svelte.test.d.ts.map +0 -1
  75. package/dist/components/composer.svelte.test.js +0 -162
  76. package/dist/components/continue.svelte.test.d.ts +0 -2
  77. package/dist/components/continue.svelte.test.d.ts.map +0 -1
  78. package/dist/components/continue.svelte.test.js +0 -109
  79. package/dist/components/conversation-chrome.svelte.test.d.ts +0 -2
  80. package/dist/components/conversation-chrome.svelte.test.d.ts.map +0 -1
  81. package/dist/components/conversation-chrome.svelte.test.js +0 -147
  82. package/dist/components/feedback.svelte.test.d.ts +0 -2
  83. package/dist/components/feedback.svelte.test.d.ts.map +0 -1
  84. package/dist/components/feedback.svelte.test.js +0 -99
  85. package/dist/components/live-fixes.svelte.test.d.ts +0 -2
  86. package/dist/components/live-fixes.svelte.test.d.ts.map +0 -1
  87. package/dist/components/live-fixes.svelte.test.js +0 -127
  88. package/dist/components/markdown-prose.svelte.test.d.ts +0 -2
  89. package/dist/components/markdown-prose.svelte.test.d.ts.map +0 -1
  90. package/dist/components/markdown-prose.svelte.test.js +0 -26
  91. package/dist/components/messages.svelte.test.d.ts +0 -2
  92. package/dist/components/messages.svelte.test.d.ts.map +0 -1
  93. package/dist/components/messages.svelte.test.js +0 -215
  94. package/dist/components/regenerate.svelte.test.d.ts +0 -2
  95. package/dist/components/regenerate.svelte.test.d.ts.map +0 -1
  96. package/dist/components/regenerate.svelte.test.js +0 -108
  97. package/dist/components/welcome.svelte.test.d.ts +0 -2
  98. package/dist/components/welcome.svelte.test.d.ts.map +0 -1
  99. package/dist/components/welcome.svelte.test.js +0 -119
  100. package/dist/composer-keys.test.d.ts +0 -2
  101. package/dist/composer-keys.test.d.ts.map +0 -1
  102. package/dist/composer-keys.test.js +0 -16
  103. package/dist/markdown.test.d.ts +0 -2
  104. package/dist/markdown.test.d.ts.map +0 -1
  105. package/dist/markdown.test.js +0 -35
  106. package/dist/reducer.test.d.ts +0 -2
  107. package/dist/reducer.test.d.ts.map +0 -1
  108. package/dist/reducer.test.js +0 -153
  109. package/dist/ui-frame-protocol.test.d.ts +0 -2
  110. package/dist/ui-frame-protocol.test.d.ts.map +0 -1
  111. package/dist/ui-frame-protocol.test.js +0 -48
  112. package/dist/ui-frame-relay.test.d.ts +0 -2
  113. package/dist/ui-frame-relay.test.d.ts.map +0 -1
  114. package/dist/ui-frame-relay.test.js +0 -36
package/dist/reducer.js CHANGED
@@ -35,8 +35,12 @@ export function describeContext(context) {
35
35
  /** Fold one stream event into the assistant message it belongs to. Returns a new object. */
36
36
  export function applyEvent(message, event) {
37
37
  switch (event.type) {
38
- case 'start':
39
- return { ...message, mode: event.mode };
38
+ case 'start': {
39
+ const next = { ...message, mode: event.mode };
40
+ // Accepted: the runtime has removed the replaced turn and stored this one.
41
+ delete next.replaces;
42
+ return next;
43
+ }
40
44
  case 'delta':
41
45
  return { ...message, text: message.text + event.text };
42
46
  case 'proposal':
@@ -145,6 +149,9 @@ export function applyEvent(message, event) {
145
149
  calls: message.usage.calls + 1,
146
150
  },
147
151
  };
152
+ case 'client_call':
153
+ // Answered by the session against the live page; nothing to show in the transcript.
154
+ return message;
148
155
  case 'error':
149
156
  return {
150
157
  ...message,
@@ -165,6 +172,15 @@ export function applyEvent(message, event) {
165
172
  };
166
173
  }
167
174
  }
175
+ /**
176
+ * Whether Continue can carry on this answer: it was cut short (output limit) or stopped, and has
177
+ * text. A dropped connection or an error is retried instead.
178
+ */
179
+ export function isContinuable(message) {
180
+ return ((message.status === 'incomplete' || message.status === 'cancelled') &&
181
+ (message.finishReason === 'length' || message.finishReason === 'cancelled') &&
182
+ !!message.text);
183
+ }
168
184
  /**
169
185
  * Prepare a retry of the turn `requestId`: returns the user message to resend and the transcript
170
186
  * without that turn (its user message and the failed assistant reply), so the retried turn
@@ -22,6 +22,11 @@ export interface ChatSessionOptions {
22
22
  conversationId: string | null;
23
23
  status: string;
24
24
  }) => void;
25
+ /**
26
+ * After Stop, how long to keep reading the stream for the runtime's `done` (so text the agent
27
+ * produced before the cancel landed still shows) before giving up locally. Default 3000 ms.
28
+ */
29
+ cancelGraceMs?: number;
25
30
  }
26
31
  export type SessionStatus = 'idle' | 'loading' | 'streaming';
27
32
  /**
@@ -48,10 +53,30 @@ export declare class ChatSession {
48
53
  private controller;
49
54
  /** The request being streamed; differs from the shown answer's id while continuing it. */
50
55
  private streamRequestId;
56
+ /** Set once Stop was pressed for the running stream (the server cancel is on its way). */
57
+ private cancelling;
58
+ private cancelTimer;
59
+ /** Bumped by each load and reset, so a superseded load drops its result. */
60
+ private loadToken;
51
61
  private readonly options;
62
+ private readonly listeners;
52
63
  constructor(options: ChatSessionOptions);
64
+ /**
65
+ * Observe this session's stream events for as long as a view needs them. A session can
66
+ * outlive the component showing it (a host moving the chat between surfaces), so views
67
+ * subscribe instead of owning the `onEvent` option. Returns the unsubscribe.
68
+ */
69
+ subscribe(listener: (event: ChatEvent) => void): () => void;
53
70
  get streaming(): boolean;
54
71
  get current(): AssistantMessage | undefined;
72
+ /**
73
+ * Forget the current conversation (the host started a new chat): stops reading a running
74
+ * stream without cancelling it on the server (the turn finishes there and stays in its
75
+ * conversation) and clears the transcript.
76
+ */
77
+ reset(): void;
78
+ /** Stop reading the running stream, if any; its `run` then leaves the session alone. */
79
+ private detach;
55
80
  /** Load an existing conversation's transcript and re-attach to a running turn if any. */
56
81
  load(conversationId: string): Promise<void>;
57
82
  /**
@@ -67,6 +92,11 @@ export declare class ChatSession {
67
92
  * in the view and in the runtime's transcript, rather than duplicated.
68
93
  */
69
94
  retry(requestId: string): Promise<void>;
95
+ /**
96
+ * The stored turn a resend of `requestId` replaces: the request itself once the runtime
97
+ * accepted it, else the turn that request was meant to replace (the runtime never stored it).
98
+ */
99
+ private replacesFor;
70
100
  /** Ask again for a new answer to the latest question, replacing the current one. */
71
101
  regenerate(requestId: string): Promise<void>;
72
102
  /** Change the latest question and answer it again, replacing that turn. */
@@ -84,8 +114,16 @@ export declare class ChatSession {
84
114
  * runtime refuses it.
85
115
  */
86
116
  rate(requestId: string, rating: TurnRating | null, comment?: string): Promise<void>;
117
+ /**
118
+ * Stop the running answer. The runtime is asked to cancel and the stream is read on until its
119
+ * `done`, so text produced before the cancel landed still shows (and a later Continue appends
120
+ * after it); after `cancelGraceMs` the stream is dropped locally. The answer shows as stopped
121
+ * at once.
122
+ */
87
123
  cancel(): void;
88
124
  private run;
125
+ /** The turn's stream is over: restore a failed continuation, go idle, tell the host. */
126
+ private finish;
89
127
  private toolName;
90
128
  private update;
91
129
  }
@@ -1 +1 @@
1
- {"version":3,"file":"session.svelte.d.ts","sourceRoot":"","sources":["../src/lib/session.svelte.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"session.svelte.d.ts","sourceRoot":"","sources":["../src/lib/session.svelte.ts"],"names":[],"mappings":"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"}
@@ -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.
@@ -18,13 +21,30 @@ export class ChatSession {
18
21
  controller;
19
22
  /** The request being streamed; differs from the shown answer's id while continuing it. */
20
23
  streamRequestId;
24
+ /** Set once Stop was pressed for the running stream (the server cancel is on its way). */
25
+ cancelling = false;
26
+ cancelTimer;
27
+ /** Bumped by each load and reset, so a superseded load drops its result. */
28
+ loadToken = 0;
21
29
  options;
30
+ listeners = new Set();
22
31
  constructor(options) {
23
32
  this.options = options;
24
33
  this.conversationId = options.conversationId ?? null;
25
34
  this.mode = options.mode;
26
35
  this.agent = options.agent ?? 'classic-agent';
27
36
  }
37
+ /**
38
+ * Observe this session's stream events for as long as a view needs them. A session can
39
+ * outlive the component showing it (a host moving the chat between surfaces), so views
40
+ * subscribe instead of owning the `onEvent` option. Returns the unsubscribe.
41
+ */
42
+ subscribe(listener) {
43
+ this.listeners.add(listener);
44
+ return () => {
45
+ this.listeners.delete(listener);
46
+ };
47
+ }
28
48
  get streaming() {
29
49
  return this.status === 'streaming';
30
50
  }
@@ -32,12 +52,43 @@ export class ChatSession {
32
52
  const last = this.messages[this.messages.length - 1];
33
53
  return last?.role === 'assistant' ? last : undefined;
34
54
  }
55
+ /**
56
+ * Forget the current conversation (the host started a new chat): stops reading a running
57
+ * stream without cancelling it on the server (the turn finishes there and stays in its
58
+ * conversation) and clears the transcript.
59
+ */
60
+ reset() {
61
+ this.loadToken += 1;
62
+ this.detach();
63
+ this.status = 'idle';
64
+ this.messages = [];
65
+ this.conversationId = null;
66
+ this.loadedConversationId = null;
67
+ this.loadError = undefined;
68
+ this.pendingFiles = [];
69
+ }
70
+ /** Stop reading the running stream, if any; its `run` then leaves the session alone. */
71
+ detach() {
72
+ const controller = this.controller;
73
+ this.controller = undefined;
74
+ this.streamRequestId = undefined;
75
+ this.cancelling = false;
76
+ clearTimeout(this.cancelTimer);
77
+ this.cancelTimer = undefined;
78
+ controller?.abort();
79
+ }
35
80
  /** Load an existing conversation's transcript and re-attach to a running turn if any. */
36
81
  async load(conversationId) {
82
+ // Another conversation's stream must not keep writing into (or finishing) this one.
83
+ this.detach();
84
+ const token = ++this.loadToken;
37
85
  this.status = 'loading';
38
86
  this.loadError = undefined;
39
87
  try {
40
88
  const detail = await this.options.client.getConversation(conversationId);
89
+ // A newer load or a reset took over while this one waited.
90
+ if (token !== this.loadToken)
91
+ return;
41
92
  this.conversationId = conversationId;
42
93
  this.loadedConversationId = conversationId;
43
94
  this.messages = detail.messages.map((m) => m.role === 'user'
@@ -51,13 +102,9 @@ export class ChatSession {
51
102
  : {
52
103
  ...newAssistantMessage(m.id.replace(/^a-/, ''), m.createdAt),
53
104
  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 } : {}),
105
+ // An answer that ended early keeps its Continue (or, after an error, its Try
106
+ // again) after a reload. A finish this client does not know reads as done.
107
+ ...loadedFinish(m.finish),
61
108
  ...(detail.feedback?.[m.id.replace(/^a-/, '')]
62
109
  ? { feedback: detail.feedback[m.id.replace(/^a-/, '')] }
63
110
  : {}),
@@ -69,6 +116,8 @@ export class ChatSession {
69
116
  await this.resume(active);
70
117
  }
71
118
  catch (err) {
119
+ if (token !== this.loadToken)
120
+ return;
72
121
  this.status = 'idle';
73
122
  this.loadError = err instanceof Error ? err.message : 'Could not load the conversation';
74
123
  }
@@ -95,10 +144,15 @@ export class ChatSession {
95
144
  ...(this.options.clientKind ? { client: this.options.clientKind } : {}),
96
145
  ...(options.replaces ? { replaces: options.replaces } : {}),
97
146
  };
147
+ const answer = newAssistantMessage(requestId);
148
+ // Until the runtime accepts the turn (`start`), remember which stored turn it replaces, so
149
+ // retrying a turn that never started still replaces the original (see `replacesFor`).
150
+ if (options.replaces)
151
+ answer.replaces = options.replaces;
98
152
  this.messages = [
99
153
  ...this.messages,
100
154
  newUserMessage(requestId, message, { attachments: files, context }),
101
- newAssistantMessage(requestId),
155
+ answer,
102
156
  ];
103
157
  await this.run(requestId, (signal, onEvent) => this.options.client.streamChat(request, { signal, onEvent }));
104
158
  }
@@ -112,8 +166,17 @@ export class ChatSession {
112
166
  const turn = turnForRetry(this.messages, requestId);
113
167
  if (!turn)
114
168
  return;
169
+ const replaces = this.replacesFor(requestId);
115
170
  this.messages = turn.rest;
116
- await this.send(turn.user.text, { files: turn.user.attachments, replaces: requestId });
171
+ await this.send(turn.user.text, { files: turn.user.attachments, replaces });
172
+ }
173
+ /**
174
+ * The stored turn a resend of `requestId` replaces: the request itself once the runtime
175
+ * accepted it, else the turn that request was meant to replace (the runtime never stored it).
176
+ */
177
+ replacesFor(requestId) {
178
+ const answer = this.messages.find((m) => m.role === 'assistant' && m.requestId === requestId);
179
+ return answer?.replaces ?? requestId;
117
180
  }
118
181
  /** Ask again for a new answer to the latest question, replacing the current one. */
119
182
  async regenerate(requestId) {
@@ -122,8 +185,9 @@ export class ChatSession {
122
185
  const turn = latestTurn(this.messages, requestId);
123
186
  if (!turn)
124
187
  return;
188
+ const replaces = this.replacesFor(requestId);
125
189
  this.messages = turn.rest;
126
- await this.send(turn.user.text, { files: turn.user.attachments, replaces: requestId });
190
+ await this.send(turn.user.text, { files: turn.user.attachments, replaces });
127
191
  }
128
192
  /** Change the latest question and answer it again, replacing that turn. */
129
193
  async edit(requestId, text) {
@@ -132,8 +196,9 @@ export class ChatSession {
132
196
  const turn = latestTurn(this.messages, requestId);
133
197
  if (!turn)
134
198
  return;
199
+ const replaces = this.replacesFor(requestId);
135
200
  this.messages = turn.rest;
136
- await this.send(text, { files: turn.user.attachments, replaces: requestId });
201
+ await this.send(text, { files: turn.user.attachments, replaces });
137
202
  }
138
203
  /**
139
204
  * Carry on the latest answer where it was cut short or stopped. The runtime asks the agent to
@@ -145,10 +210,9 @@ export class ChatSession {
145
210
  if (!conversationId || this.status !== 'idle')
146
211
  return;
147
212
  const last = this.current;
148
- if (last?.requestId !== requestId ||
149
- (last.status !== 'incomplete' && last.status !== 'cancelled') ||
150
- !last.text)
213
+ if (last?.requestId !== requestId || !isContinuable(last))
151
214
  return;
215
+ const previous = { status: last.status, finishReason: last.finishReason };
152
216
  const request = {
153
217
  requestId: crypto.randomUUID(),
154
218
  conversationId,
@@ -163,7 +227,7 @@ export class ChatSession {
163
227
  delete next.error;
164
228
  return next;
165
229
  });
166
- await this.run(requestId, (signal, onEvent) => this.options.client.streamChat(request, { signal, onEvent }), request.requestId);
230
+ await this.run(requestId, (signal, onEvent) => this.options.client.streamChat(request, { signal, onEvent }), request.requestId, previous);
167
231
  }
168
232
  /** Re-attach to a turn after reload: replays events after the last seq we hold (none here). */
169
233
  async resume(requestId) {
@@ -207,20 +271,40 @@ export class ChatSession {
207
271
  throw err;
208
272
  }
209
273
  }
274
+ /**
275
+ * Stop the running answer. The runtime is asked to cancel and the stream is read on until its
276
+ * `done`, so text produced before the cancel landed still shows (and a later Continue appends
277
+ * after it); after `cancelGraceMs` the stream is dropped locally. The answer shows as stopped
278
+ * at once.
279
+ */
210
280
  cancel() {
211
281
  const current = this.current;
212
- if (!this.controller || !current)
282
+ const controller = this.controller;
283
+ if (!controller || !current || this.cancelling)
213
284
  return;
214
- this.controller.abort();
215
- void this.options.client.cancel(this.streamRequestId ?? current.requestId);
285
+ this.cancelling = true;
286
+ const answerId = current.requestId;
287
+ this.update(answerId, (m) => m.status === 'streaming' ? { ...m, status: 'cancelled', finishReason: 'cancelled' } : m);
288
+ void this.options.client.cancel(this.streamRequestId ?? answerId);
289
+ this.cancelTimer = setTimeout(() => controller.abort(), this.options.cancelGraceMs ?? DEFAULT_CANCEL_GRACE_MS);
216
290
  }
217
291
  async run(requestId, start,
218
292
  /** The request streamed, when it is not `requestId` (a continuation of that answer). */
219
- streamRequestId = requestId) {
293
+ streamRequestId = requestId,
294
+ /** A continuation: the answer's state before it, restored when the continuation fails. */
295
+ previous) {
296
+ this.detach();
220
297
  this.status = 'streaming';
221
- this.controller = new AbortController();
298
+ const controller = new AbortController();
299
+ this.controller = controller;
222
300
  this.streamRequestId = streamRequestId;
301
+ // A reset, a load or a newer turn replaced this stream: leave the session alone.
302
+ const stale = () => this.controller !== controller;
303
+ let lastSeq = -1;
223
304
  const onEvent = (event) => {
305
+ if (stale())
306
+ return;
307
+ lastSeq = event.seq;
224
308
  if (event.type === 'start') {
225
309
  this.mode = event.mode;
226
310
  if (this.conversationId !== event.conversationId) {
@@ -233,15 +317,48 @@ export class ChatSession {
233
317
  this.options.onToolResult?.(this.toolName(requestId, event.callId), event.status);
234
318
  this.update(requestId, (m) => applyEvent(m, event));
235
319
  this.options.onEvent?.(event);
320
+ for (const listener of [...this.listeners])
321
+ listener(event);
236
322
  };
237
323
  try {
238
- const outcome = await start(this.controller.signal, onEvent);
324
+ let outcome = await start(controller.signal, onEvent);
325
+ // The connection dropped before `done`: the turn may still be running (or have just
326
+ // finished) on the server, so re-attach after the last event seen. Continue is not the
327
+ // answer here; the runtime refuses it while the turn runs.
328
+ for (let attempt = 0; outcome.kind === 'ended' && attempt < RESUME_ATTEMPTS && !stale() && !this.cancelling; attempt += 1) {
329
+ const conversationId = this.conversationId;
330
+ if (!conversationId)
331
+ break;
332
+ try {
333
+ outcome = await this.options.client.resumeTurn(conversationId, streamRequestId, lastSeq, {
334
+ signal: controller.signal,
335
+ onEvent,
336
+ });
337
+ }
338
+ catch {
339
+ // Counts as an attempt; the outcome stays `ended`.
340
+ }
341
+ }
342
+ if (stale())
343
+ return;
239
344
  if (outcome.kind === 'aborted')
240
345
  this.update(requestId, (m) => m.status === 'streaming' ? { ...m, status: 'cancelled', finishReason: 'cancelled' } : m);
241
346
  else if (outcome.kind === 'ended')
242
- this.update(requestId, (m) => m.status === 'streaming' ? { ...m, status: 'incomplete' } : m);
347
+ this.update(requestId, (m) => m.status === 'streaming'
348
+ ? {
349
+ ...m,
350
+ status: 'error',
351
+ error: {
352
+ code: 'network',
353
+ message: 'The connection was lost before the answer finished.',
354
+ retryable: true,
355
+ },
356
+ }
357
+ : m);
243
358
  }
244
359
  catch (err) {
360
+ if (stale())
361
+ return;
245
362
  const code = err instanceof ApiRequestError ? err.code : 'network';
246
363
  const text = err instanceof Error ? err.message : 'The request failed';
247
364
  this.update(requestId, (m) => ({
@@ -255,6 +372,28 @@ export class ChatSession {
255
372
  }));
256
373
  }
257
374
  finally {
375
+ if (!stale())
376
+ this.finish(requestId, previous);
377
+ }
378
+ }
379
+ /** The turn's stream is over: restore a failed continuation, go idle, tell the host. */
380
+ finish(requestId, previous) {
381
+ // A failed continuation keeps the text so far continuable; the error shows inline.
382
+ if (previous)
383
+ this.update(requestId, (m) => {
384
+ if (m.status !== 'error')
385
+ return m;
386
+ const next = { ...m, status: previous.status };
387
+ if (previous.finishReason)
388
+ next.finishReason = previous.finishReason;
389
+ else
390
+ delete next.finishReason;
391
+ return next;
392
+ });
393
+ {
394
+ clearTimeout(this.cancelTimer);
395
+ this.cancelTimer = undefined;
396
+ this.cancelling = false;
258
397
  this.controller = undefined;
259
398
  this.streamRequestId = undefined;
260
399
  this.status = 'idle';
@@ -274,6 +413,20 @@ export class ChatSession {
274
413
  this.messages = this.messages.map((m) => m.role === 'assistant' && m.requestId === requestId ? fn(m) : m);
275
414
  }
276
415
  }
416
+ /** Status for a stored answer's `finish` (absent: it finished normally). */
417
+ function loadedFinish(finish) {
418
+ if (finish === 'length')
419
+ return { status: 'incomplete', finishReason: 'length' };
420
+ if (finish === 'cancelled')
421
+ return { status: 'cancelled', finishReason: 'cancelled' };
422
+ if (finish === 'error')
423
+ return {
424
+ status: 'error',
425
+ finishReason: 'error',
426
+ error: { code: 'upstream_error', message: 'The answer did not finish.', retryable: true },
427
+ };
428
+ return { status: 'done' };
429
+ }
277
430
  export function createChatSession(options) {
278
431
  return new ChatSession(options);
279
432
  }
@@ -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,56 @@
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
+ /** The corner a dropped launcher snaps to. */
48
+ export declare function nearestCorner(point: Point, viewport: Size): Corner;
49
+ /**
50
+ * The rect after dragging a resize handle by `(dx, dy)`. The opposite edge stays put, also when
51
+ * a limit stops the dragged edge.
52
+ */
53
+ export declare function resizeRect(rect: Rect, direction: ResizeDirection, dx: number, dy: number, viewport: Size): Rect;
54
+ /** Was a dragged window released against the right edge (the dock side)? */
55
+ export declare function isDockDrop(pointer: Point, viewport: Size): boolean;
56
+ //# 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,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,99 @@
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
+ /** The corner a dropped launcher snaps to. */
67
+ export function nearestCorner(point, viewport) {
68
+ const vertical = point.y < viewport.height / 2 ? 'top' : 'bottom';
69
+ const horizontal = point.x < viewport.width / 2 ? 'left' : 'right';
70
+ return `${vertical}-${horizontal}`;
71
+ }
72
+ /**
73
+ * The rect after dragging a resize handle by `(dx, dy)`. The opposite edge stays put, also when
74
+ * a limit stops the dragged edge.
75
+ */
76
+ export function resizeRect(rect, direction, dx, dy, viewport) {
77
+ const { margin } = FLOAT_LIMITS;
78
+ let { x, y, width, height } = rect;
79
+ const right = x + width;
80
+ const bottom = y + height;
81
+ if (direction.includes('e'))
82
+ width = Math.min(width + dx, viewport.width - margin - x);
83
+ if (direction.includes('s'))
84
+ height = Math.min(height + dy, viewport.height - margin - y);
85
+ if (direction.includes('w'))
86
+ width = Math.min(width - dx, right - margin);
87
+ if (direction.includes('n'))
88
+ height = Math.min(height - dy, bottom - margin);
89
+ ({ width, height } = clampSize({ width, height }, viewport));
90
+ if (direction.includes('w'))
91
+ x = right - width;
92
+ if (direction.includes('n'))
93
+ y = bottom - height;
94
+ return clampRect({ x, y, width, height }, viewport);
95
+ }
96
+ /** Was a dragged window released against the right edge (the dock side)? */
97
+ export function isDockDrop(pointer, viewport) {
98
+ return pointer.x >= viewport.width - DOCK_THRESHOLD;
99
+ }