@classic-homes/agent-chat 0.10.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 (111) hide show
  1. package/README.md +57 -2
  2. package/dist/components/AgentChat.svelte +61 -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 -4
  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 +46 -0
  49. package/dist/session.svelte.d.ts.map +1 -1
  50. package/dist/session.svelte.js +208 -13
  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/conversation-chrome.svelte.test.d.ts +0 -2
  77. package/dist/components/conversation-chrome.svelte.test.d.ts.map +0 -1
  78. package/dist/components/conversation-chrome.svelte.test.js +0 -147
  79. package/dist/components/feedback.svelte.test.d.ts +0 -2
  80. package/dist/components/feedback.svelte.test.d.ts.map +0 -1
  81. package/dist/components/feedback.svelte.test.js +0 -99
  82. package/dist/components/live-fixes.svelte.test.d.ts +0 -2
  83. package/dist/components/live-fixes.svelte.test.d.ts.map +0 -1
  84. package/dist/components/live-fixes.svelte.test.js +0 -127
  85. package/dist/components/markdown-prose.svelte.test.d.ts +0 -2
  86. package/dist/components/markdown-prose.svelte.test.d.ts.map +0 -1
  87. package/dist/components/markdown-prose.svelte.test.js +0 -26
  88. package/dist/components/messages.svelte.test.d.ts +0 -2
  89. package/dist/components/messages.svelte.test.d.ts.map +0 -1
  90. package/dist/components/messages.svelte.test.js +0 -202
  91. package/dist/components/regenerate.svelte.test.d.ts +0 -2
  92. package/dist/components/regenerate.svelte.test.d.ts.map +0 -1
  93. package/dist/components/regenerate.svelte.test.js +0 -108
  94. package/dist/components/welcome.svelte.test.d.ts +0 -2
  95. package/dist/components/welcome.svelte.test.d.ts.map +0 -1
  96. package/dist/components/welcome.svelte.test.js +0 -119
  97. package/dist/composer-keys.test.d.ts +0 -2
  98. package/dist/composer-keys.test.d.ts.map +0 -1
  99. package/dist/composer-keys.test.js +0 -16
  100. package/dist/markdown.test.d.ts +0 -2
  101. package/dist/markdown.test.d.ts.map +0 -1
  102. package/dist/markdown.test.js +0 -35
  103. package/dist/reducer.test.d.ts +0 -2
  104. package/dist/reducer.test.d.ts.map +0 -1
  105. package/dist/reducer.test.js +0 -153
  106. package/dist/ui-frame-protocol.test.d.ts +0 -2
  107. package/dist/ui-frame-protocol.test.d.ts.map +0 -1
  108. package/dist/ui-frame-protocol.test.js +0 -48
  109. package/dist/ui-frame-relay.test.d.ts +0 -2
  110. package/dist/ui-frame-relay.test.d.ts.map +0 -1
  111. 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
  /**
@@ -46,10 +51,32 @@ export declare class ChatSession {
46
51
  status: "uploaded" | "indexed" | "failed";
47
52
  }[];
48
53
  private controller;
54
+ /** The request being streamed; differs from the shown answer's id while continuing it. */
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;
49
61
  private readonly options;
62
+ private readonly listeners;
50
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;
51
70
  get streaming(): boolean;
52
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;
53
80
  /** Load an existing conversation's transcript and re-attach to a running turn if any. */
54
81
  load(conversationId: string): Promise<void>;
55
82
  /**
@@ -65,10 +92,21 @@ export declare class ChatSession {
65
92
  * in the view and in the runtime's transcript, rather than duplicated.
66
93
  */
67
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;
68
100
  /** Ask again for a new answer to the latest question, replacing the current one. */
69
101
  regenerate(requestId: string): Promise<void>;
70
102
  /** Change the latest question and answer it again, replacing that turn. */
71
103
  edit(requestId: string, text: string): Promise<void>;
104
+ /**
105
+ * Carry on the latest answer where it was cut short or stopped. The runtime asks the agent to
106
+ * continue and appends the new text to that answer, so the view streams into the same message
107
+ * (no new question shows).
108
+ */
109
+ continue(requestId: string): Promise<void>;
72
110
  /** Re-attach to a turn after reload: replays events after the last seq we hold (none here). */
73
111
  resume(requestId: string): Promise<void>;
74
112
  /**
@@ -76,8 +114,16 @@ export declare class ChatSession {
76
114
  * runtime refuses it.
77
115
  */
78
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
+ */
79
123
  cancel(): void;
80
124
  private run;
125
+ /** The turn's stream is over: restore a failed continuation, go idle, tell the host. */
126
+ private finish;
81
127
  private toolName;
82
128
  private update;
83
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.
@@ -16,13 +19,32 @@ export class ChatSession {
16
19
  loadError = $state(undefined);
17
20
  pendingFiles = $state([]);
18
21
  controller;
22
+ /** The request being streamed; differs from the shown answer's id while continuing it. */
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;
19
29
  options;
30
+ listeners = new Set();
20
31
  constructor(options) {
21
32
  this.options = options;
22
33
  this.conversationId = options.conversationId ?? null;
23
34
  this.mode = options.mode;
24
35
  this.agent = options.agent ?? 'classic-agent';
25
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
+ }
26
48
  get streaming() {
27
49
  return this.status === 'streaming';
28
50
  }
@@ -30,12 +52,43 @@ export class ChatSession {
30
52
  const last = this.messages[this.messages.length - 1];
31
53
  return last?.role === 'assistant' ? last : undefined;
32
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
+ }
33
80
  /** Load an existing conversation's transcript and re-attach to a running turn if any. */
34
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;
35
85
  this.status = 'loading';
36
86
  this.loadError = undefined;
37
87
  try {
38
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;
39
92
  this.conversationId = conversationId;
40
93
  this.loadedConversationId = conversationId;
41
94
  this.messages = detail.messages.map((m) => m.role === 'user'
@@ -49,7 +102,9 @@ export class ChatSession {
49
102
  : {
50
103
  ...newAssistantMessage(m.id.replace(/^a-/, ''), m.createdAt),
51
104
  text: m.content.map((c) => c.text).join('\n'),
52
- status: 'done',
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),
53
108
  ...(detail.feedback?.[m.id.replace(/^a-/, '')]
54
109
  ? { feedback: detail.feedback[m.id.replace(/^a-/, '')] }
55
110
  : {}),
@@ -61,6 +116,8 @@ export class ChatSession {
61
116
  await this.resume(active);
62
117
  }
63
118
  catch (err) {
119
+ if (token !== this.loadToken)
120
+ return;
64
121
  this.status = 'idle';
65
122
  this.loadError = err instanceof Error ? err.message : 'Could not load the conversation';
66
123
  }
@@ -87,10 +144,15 @@ export class ChatSession {
87
144
  ...(this.options.clientKind ? { client: this.options.clientKind } : {}),
88
145
  ...(options.replaces ? { replaces: options.replaces } : {}),
89
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;
90
152
  this.messages = [
91
153
  ...this.messages,
92
154
  newUserMessage(requestId, message, { attachments: files, context }),
93
- newAssistantMessage(requestId),
155
+ answer,
94
156
  ];
95
157
  await this.run(requestId, (signal, onEvent) => this.options.client.streamChat(request, { signal, onEvent }));
96
158
  }
@@ -104,8 +166,17 @@ export class ChatSession {
104
166
  const turn = turnForRetry(this.messages, requestId);
105
167
  if (!turn)
106
168
  return;
169
+ const replaces = this.replacesFor(requestId);
107
170
  this.messages = turn.rest;
108
- 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;
109
180
  }
110
181
  /** Ask again for a new answer to the latest question, replacing the current one. */
111
182
  async regenerate(requestId) {
@@ -114,8 +185,9 @@ export class ChatSession {
114
185
  const turn = latestTurn(this.messages, requestId);
115
186
  if (!turn)
116
187
  return;
188
+ const replaces = this.replacesFor(requestId);
117
189
  this.messages = turn.rest;
118
- await this.send(turn.user.text, { files: turn.user.attachments, replaces: requestId });
190
+ await this.send(turn.user.text, { files: turn.user.attachments, replaces });
119
191
  }
120
192
  /** Change the latest question and answer it again, replacing that turn. */
121
193
  async edit(requestId, text) {
@@ -124,8 +196,38 @@ export class ChatSession {
124
196
  const turn = latestTurn(this.messages, requestId);
125
197
  if (!turn)
126
198
  return;
199
+ const replaces = this.replacesFor(requestId);
127
200
  this.messages = turn.rest;
128
- await this.send(text, { files: turn.user.attachments, replaces: requestId });
201
+ await this.send(text, { files: turn.user.attachments, replaces });
202
+ }
203
+ /**
204
+ * Carry on the latest answer where it was cut short or stopped. The runtime asks the agent to
205
+ * continue and appends the new text to that answer, so the view streams into the same message
206
+ * (no new question shows).
207
+ */
208
+ async continue(requestId) {
209
+ const conversationId = this.conversationId;
210
+ if (!conversationId || this.status !== 'idle')
211
+ return;
212
+ const last = this.current;
213
+ if (last?.requestId !== requestId || !isContinuable(last))
214
+ return;
215
+ const previous = { status: last.status, finishReason: last.finishReason };
216
+ const request = {
217
+ requestId: crypto.randomUUID(),
218
+ conversationId,
219
+ agent: this.agent,
220
+ message: 'Continue',
221
+ continues: requestId,
222
+ ...(this.options.clientKind ? { client: this.options.clientKind } : {}),
223
+ };
224
+ this.update(requestId, (m) => {
225
+ const next = { ...m, status: 'streaming' };
226
+ delete next.finishReason;
227
+ delete next.error;
228
+ return next;
229
+ });
230
+ await this.run(requestId, (signal, onEvent) => this.options.client.streamChat(request, { signal, onEvent }), request.requestId, previous);
129
231
  }
130
232
  /** Re-attach to a turn after reload: replays events after the last seq we hold (none here). */
131
233
  async resume(requestId) {
@@ -169,17 +271,40 @@ export class ChatSession {
169
271
  throw err;
170
272
  }
171
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
+ */
172
280
  cancel() {
173
281
  const current = this.current;
174
- if (!this.controller || !current)
282
+ const controller = this.controller;
283
+ if (!controller || !current || this.cancelling)
175
284
  return;
176
- this.controller.abort();
177
- void this.options.client.cancel(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);
178
290
  }
179
- async run(requestId, start) {
291
+ async run(requestId, start,
292
+ /** The request streamed, when it is not `requestId` (a continuation of that answer). */
293
+ streamRequestId = requestId,
294
+ /** A continuation: the answer's state before it, restored when the continuation fails. */
295
+ previous) {
296
+ this.detach();
180
297
  this.status = 'streaming';
181
- this.controller = new AbortController();
298
+ const controller = new AbortController();
299
+ this.controller = controller;
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;
182
304
  const onEvent = (event) => {
305
+ if (stale())
306
+ return;
307
+ lastSeq = event.seq;
183
308
  if (event.type === 'start') {
184
309
  this.mode = event.mode;
185
310
  if (this.conversationId !== event.conversationId) {
@@ -192,15 +317,48 @@ export class ChatSession {
192
317
  this.options.onToolResult?.(this.toolName(requestId, event.callId), event.status);
193
318
  this.update(requestId, (m) => applyEvent(m, event));
194
319
  this.options.onEvent?.(event);
320
+ for (const listener of [...this.listeners])
321
+ listener(event);
195
322
  };
196
323
  try {
197
- 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;
198
344
  if (outcome.kind === 'aborted')
199
345
  this.update(requestId, (m) => m.status === 'streaming' ? { ...m, status: 'cancelled', finishReason: 'cancelled' } : m);
200
346
  else if (outcome.kind === 'ended')
201
- 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);
202
358
  }
203
359
  catch (err) {
360
+ if (stale())
361
+ return;
204
362
  const code = err instanceof ApiRequestError ? err.code : 'network';
205
363
  const text = err instanceof Error ? err.message : 'The request failed';
206
364
  this.update(requestId, (m) => ({
@@ -214,7 +372,30 @@ export class ChatSession {
214
372
  }));
215
373
  }
216
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;
217
397
  this.controller = undefined;
398
+ this.streamRequestId = undefined;
218
399
  this.status = 'idle';
219
400
  const final = this.messages.find((m) => m.role === 'assistant' && m.requestId === requestId);
220
401
  this.options.onTurnEnd?.({
@@ -232,6 +413,20 @@ export class ChatSession {
232
413
  this.messages = this.messages.map((m) => m.role === 'assistant' && m.requestId === requestId ? fn(m) : m);
233
414
  }
234
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
+ }
235
430
  export function createChatSession(options) {
236
431
  return new ChatSession(options);
237
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
+ }