@xh/hoist-chat-kit 1.0.0 → 2.0.0-SNAPSHOT.1793828979839

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -12,6 +12,42 @@
12
12
  3. Plain ASCII punctuation only. Use " - " for in-sentence breaks, never an em dash.
13
13
  -->
14
14
 
15
+ ## 2.0.0-SNAPSHOT - unreleased
16
+
17
+ ### 💥 Breaking Changes (upgrade difficulty: 🟢 LOW - remove the busy-line settings)
18
+
19
+ See [`docs/upgrade-notes/v2-upgrade-notes.md`](docs/upgrade-notes/v2-upgrade-notes.md) for
20
+ detailed, step-by-step upgrade instructions with before/after code examples.
21
+
22
+ * `ChatStatusRegion` now shows only what stops a send or needs the user's action. A reply in
23
+ progress, or one waiting for the user's answer, shows no line, as the reply shows its progress.
24
+ * Removed the busy line's settings: `showStopHint` and `assistantName` on `ChatStatusRegion`,
25
+ `ChatStatusRegion.defaults` with its `ChatStatusRegionDefaults` type, and
26
+ `ChatKit.defaults.busyIcon`. The Stop button names Esc in its tooltip.
27
+ * Removed the `xh-chat-status-region__lead`, `xh-chat-status-region__hint`,
28
+ `xh-chat-status-region--busy` and `xh-chat-status-region--attention` classes, which the region no
29
+ longer renders.
30
+
31
+ ### 🎁 New Features
32
+
33
+ * `ChatThreadsModel` now blocks sends while the store lists a thread as running and its last reply
34
+ was left running, or it has no messages yet. `getChatComposerStatus()` reports the block with
35
+ Refresh Status, or as `detached` when the last reply is.
36
+
37
+ ### 🐞 Bug Fixes
38
+
39
+ * Fixed Refresh Status in a `ChatThreadsModel` chat to reload the thread list too. The list could
40
+ show a thread as running after its run had finished.
41
+ * Fixed a reply left running on the server, live, loaded or replayed, to show its unfinished server
42
+ calls and sub-agents as "Not finished yet", not as failed, cancelled or "Stopped". Once the
43
+ conversation moves past the reply, it reads as complete and its open work closes.
44
+
45
+ ### ✨ Styles
46
+
47
+ * Added the `xh-chat-timeline--detached`, `xh-chat-timeline__item--unfinished`,
48
+ `xh-chat-tool-call--unfinished` and `xh-chat-tool-call__glyph--unfinished` classes. A detached
49
+ reply's timeline no longer takes `xh-chat-timeline--stopped`.
50
+
15
51
  ## 1.0.0 - 2026-11-02
16
52
 
17
53
  The first release of Hoist Chat Kit, a library of chat components for Hoist React applications.
package/core/ChatKit.ts CHANGED
@@ -17,13 +17,6 @@ export interface ChatKitDefaults {
17
17
  */
18
18
  assistantIcon: () => ReactElement;
19
19
 
20
- /**
21
- * Returns the icon beside "Assistant is working" in the composer's status region, for an
22
- * app with a busy glyph of its own. Called for each use. Return null from it for no icon.
23
- * Null for the kit's own spinner. Default null.
24
- */
25
- busyIcon: () => ReactElement;
26
-
27
20
  /**
28
21
  * What a chat spinner does under reduced motion - when the browser reports
29
22
  * `prefers-reduced-motion: reduce`, or inside an element with class `xh-chat--reduced-motion`.
@@ -122,7 +115,6 @@ export class ChatKit {
122
115
  /** Kit-wide settings - see {@link ChatKitDefaults}. */
123
116
  static defaults: ChatKitDefaults = {
124
117
  assistantIcon: () => Icon.ai(),
125
- busyIcon: null,
126
118
  reducedMotionSpinner: 'static',
127
119
  stallAfterMs: 30_000,
128
120
  drafts: 'local',
package/core/ChatModel.ts CHANGED
@@ -84,7 +84,9 @@ import {genChatId} from '../impl/LangUtils';
84
84
  import {
85
85
  createTextPart,
86
86
  getClientToolCalls,
87
+ isDetachedReply,
87
88
  SETTLE_REASONS,
89
+ settleOpenCalls,
88
90
  settleOpenParts,
89
91
  settleStreamingParts,
90
92
  updatePart
@@ -548,7 +550,8 @@ export class ChatModel extends HoistModel implements ChatComposerHost, ChatTrans
548
550
  @computed
549
551
  get waitingToolCalls(): ChatToolCallPart[] {
550
552
  const {lastMessage} = this;
551
- if (this.isBusy || !lastMessage?.isAssistant) return [];
553
+ // A detached reply's open calls wait on the server, not the user.
554
+ if (this.isBusy || !lastMessage?.isAssistant || isDetachedReply(lastMessage)) return [];
552
555
  const isPausedOnInterrupts = !!this.pausedInterruptSet;
553
556
  return lastMessage.toolCalls.filter(
554
557
  it =>
@@ -717,7 +720,7 @@ export class ChatModel extends HoistModel implements ChatComposerHost, ChatTrans
717
720
  this.modelTools = tools.filter(
718
721
  it => it.sendToModel ?? (it.execute != null || !!it.interactive)
719
722
  );
720
- this.messages = restoreMessages(initialMessages);
723
+ this.messages = this.restore(initialMessages);
721
724
  this.addArtifactsOf(this.messages);
722
725
  tools.forEach(it => {
723
726
  if (it.interactive && !it.execute && !it.renderer) {
@@ -798,7 +801,8 @@ export class ChatModel extends HoistModel implements ChatComposerHost, ChatTrans
798
801
  /**
799
802
  * Stop following the current turn, without cancelling it. The message is kept as 'aborted',
800
803
  * as with `stop`. In server mode, a turn with a step in flight is marked `metadata.detached`,
801
- * as the server finishes it on its own, and a reload of the thread later shows its answer.
804
+ * as the server finishes it on its own, and a reload of the thread later shows its answer. Its
805
+ * unfinished tool calls and sub-agents stay open, where a stop closes them as cancelled.
802
806
  */
803
807
  detach() {
804
808
  this.abortTurn('detach');
@@ -1135,7 +1139,8 @@ export class ChatModel extends HoistModel implements ChatComposerHost, ChatTrans
1135
1139
  * that was pending or streaming becomes 'aborted', with its unfinished tool calls failed. A
1136
1140
  * paused turn at the end of the history stays paused, so the user can still answer its tool
1137
1141
  * calls - or send a new message, which closes them. A paused turn earlier in the history is
1138
- * closed.
1142
+ * closed. So is a detached reply - see {@link detach} - except at the end of the history,
1143
+ * where its unfinished tool calls and sub-agents stay open.
1139
1144
  *
1140
1145
  * In server mode, a turn still running on the server is detached rather than cancelled - see
1141
1146
  * {@link detach}.
@@ -1709,6 +1714,30 @@ export class ChatModel extends HoistModel implements ChatComposerHost, ChatTrans
1709
1714
  }
1710
1715
  }
1711
1716
 
1717
+ /** Messages from stored specs, closed as `loadMessages` describes. */
1718
+ private restore(specs: ChatMessageSpec[]): ChatMessage[] {
1719
+ const messages = restoreMessages(specs),
1720
+ last = messages[messages.length - 1];
1721
+ if (isDetachedReply(last)) messages[messages.length - 1] = this.closeClientCalls(last);
1722
+ return messages;
1723
+ }
1724
+
1725
+ /**
1726
+ * A detached reply with the open calls that the browser would run or answer closed - those to
1727
+ * a tool of the chat with an `execute`, or an interactive one. The turn that would run them
1728
+ * is over, and the server never does. Calls the server runs, display tools included, stay open.
1729
+ */
1730
+ private closeClientCalls(message: ChatMessage): ChatMessage {
1731
+ return settleOpenCalls(
1732
+ message,
1733
+ it => {
1734
+ const spec = it.providerExecuted ? null : this.getOwnToolSpec(it.toolName);
1735
+ return !!(spec?.execute || spec?.interactive);
1736
+ },
1737
+ SETTLE_REASONS.interrupted
1738
+ );
1739
+ }
1740
+
1712
1741
  /** The spec for a tool in `tools`, or null - the only kind that runs or answers a call. */
1713
1742
  private getOwnToolSpec(name: string): ChatToolSpec {
1714
1743
  return this.tools.find(it => it.name === name) ?? null;
@@ -1759,7 +1788,7 @@ export class ChatModel extends HoistModel implements ChatComposerHost, ChatTrans
1759
1788
  this.abortTurn(reason);
1760
1789
  this._threadId = threadId ?? genChatId();
1761
1790
  this.isThreadIdProvisional = threadId == null || !hasHistory(specs);
1762
- this.messages = restoreMessages(specs);
1791
+ this.messages = this.restore(specs);
1763
1792
  this.interruptSet = restoreInterruptSet(this.messages);
1764
1793
  // A history rebuilt from events, rather than saved by the chat, has no marks on the
1765
1794
  // server calls that a paused reply's interrupts wait on.
@@ -1788,7 +1817,8 @@ export class ChatModel extends HoistModel implements ChatComposerHost, ChatTrans
1788
1817
  if (this.interruptSet) this.setInterruptSet(declineRest(this.interruptSet));
1789
1818
  this.closePausedTurn();
1790
1819
 
1791
- const history = getHistory(),
1820
+ // A detached reply the conversation moves past gets no more results.
1821
+ const history = getHistory().map(it => (isDetachedReply(it) ? closeMovedPast(it) : it)),
1792
1822
  userMessage = findLast(history, it => it.isUser),
1793
1823
  message = new ChatMessage({role: 'assistant', status: 'pending'}),
1794
1824
  turn = createTurn(message, 0, true, {
@@ -1871,10 +1901,11 @@ export class ChatModel extends HoistModel implements ChatComposerHost, ChatTrans
1871
1901
  this.logError('Could not check the reply for catalog UI', e);
1872
1902
  }
1873
1903
 
1874
- const settled = settleOpenParts(
1875
- message,
1876
- SETTLE_REASONS[status],
1877
- status === 'aborted'
1904
+ // A detached reply's work goes on on the server, so its open calls and sub-agents stay open.
1905
+ const settled = (
1906
+ detached
1907
+ ? this.closeClientCalls(settleStreamingParts(message))
1908
+ : settleOpenParts(message, SETTLE_REASONS[status], status === 'aborted')
1878
1909
  ).withChanges({
1879
1910
  status,
1880
1911
  error: error ? errorText(error) : null,
@@ -2158,7 +2189,8 @@ function stopTurnClock(turn: ChatTurn): number {
2158
2189
 
2159
2190
  /**
2160
2191
  * Messages from stored specs, with anything left in progress closed - see `loadMessages`. A
2161
- * paused turn at the end of the specs stays paused.
2192
+ * paused turn at the end of the specs stays paused. So does the open work of a detached reply at
2193
+ * the end, which the server may still finish.
2162
2194
  */
2163
2195
  function restoreMessages(specs: ChatMessageSpec[]): ChatMessage[] {
2164
2196
  const messages = specs.map(it => new ChatMessage(it)),
@@ -2178,11 +2210,29 @@ function restoreMessages(specs: ChatMessageSpec[]): ChatMessage[] {
2178
2210
  status: 'aborted'
2179
2211
  });
2180
2212
  default:
2181
- return settleOpenParts(message, SETTLE_REASONS.interrupted);
2213
+ if (!isDetachedReply(message)) {
2214
+ return settleOpenParts(message, SETTLE_REASONS.interrupted);
2215
+ }
2216
+ return idx === lastIdx ? settleStreamingParts(message) : closeMovedPast(message);
2182
2217
  }
2183
2218
  });
2184
2219
  }
2185
2220
 
2221
+ /**
2222
+ * A detached reply that the conversation moved past, as complete, with its open work closed - as
2223
+ * for a paused reply the user moved past. The chat will not learn how its run ended.
2224
+ */
2225
+ function closeMovedPast(message: ChatMessage): ChatMessage {
2226
+ // A call approved for a resume gets no result either, once nothing follows the reply.
2227
+ const parts = message.parts.map(it =>
2228
+ it.type === 'toolCall' && it.awaitsResume ? {...it, awaitsResume: false} : it
2229
+ );
2230
+ return settleOpenParts(message.withChanges({parts}), SETTLE_REASONS.complete).withChanges({
2231
+ status: 'complete',
2232
+ metadata: omit(message.metadata, 'detached')
2233
+ });
2234
+ }
2235
+
2186
2236
  /** The tools a catalog adds to a chat - see {@link ChatModelConfig.catalog}. */
2187
2237
  function getCatalogTools(catalog: ChatUiCatalog, historyMode: ChatHistoryMode): ChatToolSpec[] {
2188
2238
  if (!catalog) return [];
package/core/README.md CHANGED
@@ -418,6 +418,15 @@ chatModel.runOptions = {documentIds: selectedIds, profile: 'thorough'};
418
418
  | `clear()`, `loadMessages()` | Discarded | No | As for `detach()` |
419
419
  | `destroy()` | Discarded | No | The run goes on |
420
420
 
421
+ A detached reply keeps the server's unfinished tool calls and sub-agents open, as the server may
422
+ still finish them. Calls to a tool of the chat with an `execute`, or an interactive one, close as
423
+ interrupted, as the turn that would run them is over. The same holds for the last reply of a
424
+ history loaded with `loadMessages()`, if it is detached.
425
+
426
+ When the next turn starts, the reply becomes `complete`, without `metadata.detached`. Its open work
427
+ closes as "No result - the conversation moved on", so that the history a request sends has a
428
+ result for every call. A reply that a stop ended closes its open work as cancelled.
429
+
421
430
  The turn's signal aborts with a `ChatAbortError`. Its `name` is `AbortError`, and its `reason` is
422
431
  `stop`, `detach`, `clear`, `load` or `destroy`. A client tool can read the reason from
423
432
  `context.signal.reason`. In client mode, a detach and a clear only abort, as a stop does, with no
@@ -930,7 +939,10 @@ The composer's status region holds the thread's next action. `ChatComposerModel.
930
939
  reads it as a `ChatComposerStatus`: a `type`, the reply's display status, at most one `action`,
931
940
  and the reason. `getChatComposerStatus()` is the same reading as a pure function. One status
932
941
  shows at a time, in this order: blocked, offline, failed or detached, stalled, waiting to retry,
933
- paused, busy, and a restored draft.
942
+ paused, busy, and a restored draft. A send that `ChatThreadsModel` blocks while the server runs the
943
+ turn offers Refresh Status, and reads as detached when the last reply is. The desktop
944
+ `ChatStatusRegion` shows a line only for what stops a send or needs the user's action - see
945
+ [Recover from a failed reply](../desktop/cmp/chat/README.md#recover-from-a-failed-reply).
934
946
 
935
947
  The action for a failure depends on whether a resend is safe. `isChatRetrySafe(message,
936
948
  historyMode)` decides it:
@@ -1177,7 +1189,7 @@ The kit records these keys in the `metadata` of an assistant message:
1177
1189
  | `retryAfterSeconds` | Wait the backend asked for before a retry, on a failed run. |
1178
1190
  | `runId` | Id of the latest run, from `RUN_STARTED`. |
1179
1191
  | `serverId` | The server's id for the message, from `xh.chat.message` or `RUN_FINISHED`. Also set on a user message. |
1180
- | `detached` | True on a reply left running on the server by `detach()`, `clear()` or `loadMessages()`, in server mode. |
1192
+ | `detached` | True on a reply left running on the server by `detach()`, `clear()` or `loadMessages()`, in server mode. Its unfinished tool calls and sub-agents stay open - see [Stop or detach a turn](#stop-or-detach-a-turn). |
1181
1193
  | `feedback` | The user's rating, as `{rating, comment?}`. History usually restores the rating alone. |
1182
1194
  | `artifacts` | `ChatArtifact[]` that the reply's tools produced, for generative UI. Restored to `artifacts` with the history. |
1183
1195
  | `catalogVersion` | Version of the chat's catalog, on a reply that shows its UI. |
@@ -1393,7 +1405,6 @@ Fields of `ChatKit.defaults`. See `ChatKit.ts`.
1393
1405
  | Field | Type | Description |
1394
1406
  |-------|------|-------------|
1395
1407
  | `assistantIcon` | `() => ReactElement` | Returns the icon that marks the assistant. A component's `assistantIcon` prop overrides it. Default `() => Icon.ai()`. |
1396
- | `busyIcon` | `() => ReactElement` | Returns the icon beside "Assistant is working" in the status region. Return null from it for no icon. Null for the kit's spinner. Default null. |
1397
1408
  | `reducedMotionSpinner` | `'static' \| 'spin'` | Under reduced motion, `static` swaps the chat's spinners for a static icon, and `spin` keeps Hoist's spinner. Default `static`. |
1398
1409
  | `stallAfterMs` | `number` | Milliseconds with no data from the server before a reply shows as stalled. `ChatModelConfig.stallAfterMs` overrides it. Default 30,000. |
1399
1410
  | `drafts` | `'local' \| 'memory'` | Where the composer keeps its unsent draft text: local storage, per thread, or memory only. Default `local`. See [Keep drafts](#keep-drafts). |
@@ -271,8 +271,7 @@ export class ChatComposerModel extends HoistModel {
271
271
  }
272
272
 
273
273
  /**
274
- * The status the composer's status region shows now, or null - see
275
- * {@link getChatComposerStatus}.
274
+ * The thread's status for the composer now, or null - see {@link getChatComposerStatus}.
276
275
  *
277
276
  * @param now - epoch millis to read the clock-based states at. Default now.
278
277
  */
@@ -3,8 +3,9 @@ import type {ChatHistoryMode} from '../ChatModel';
3
3
  import {type ChatReplyStatus, getChatReplyStatus} from '../ChatReplyStatus';
4
4
 
5
5
  /**
6
- * What the composer's status region shows - the one thing about the thread that the user should
7
- * know before the next send, with at most one action. See {@link getChatComposerStatus}.
6
+ * The thread's status for the composer - the one thing about the thread that the user should know
7
+ * before the next send, with at most one action. See {@link getChatComposerStatus}. The desktop
8
+ * `ChatStatusRegion` shows only those that stop a send or need the user's action.
8
9
  *
9
10
  * - `blocked` - the host gives a reason that sending must wait.
10
11
  * - `offline` - the browser is offline. The draft is kept, and nothing sends by itself.
@@ -39,7 +40,7 @@ export type ChatComposerStatusType =
39
40
  */
40
41
  export type ChatComposerAction = 'tryAgain' | 'refreshStatus' | 'cancelRetry' | 'discardDraft';
41
42
 
42
- /** The status the composer's status region shows - see {@link getChatComposerStatus}. */
43
+ /** The thread's status for the composer - see {@link getChatComposerStatus}. */
43
44
  export interface ChatComposerStatus {
44
45
  type: ChatComposerStatusType;
45
46
 
@@ -105,12 +106,14 @@ export interface ChatComposerStatusSpec {
105
106
  }
106
107
 
107
108
  /**
108
- * The status the composer's status region shows, or null to show nothing. A pure function of its
109
- * input, so a component re-reads it as the clock ticks.
109
+ * The thread's status for the composer, or null for none. A pure function of its input, so a
110
+ * component re-reads it as the clock ticks. It reports every status, for a layout of an app's
111
+ * own - the desktop `ChatStatusRegion` shows a line for some of them only.
110
112
  *
111
113
  * One status shows at a time, the first that applies in this order: blocked, offline, failed or
112
114
  * detached, stalled, waiting to retry, paused, busy, and a restored draft. A reply the server
113
- * interrupted or cancelled counts as failed.
115
+ * interrupted or cancelled counts as failed. A send that a thread layer blocks while the server
116
+ * runs the thread's turn offers Refresh Status, and reads as detached if the last reply is.
114
117
  *
115
118
  * A failure that is retry-safe offers Try Again. One that is not offers Refresh Status if the host
116
119
  * can refresh, else Try Again, and says the run may still be running. A retry-safe failure for
@@ -146,7 +149,17 @@ export function getChatComposerStatus(spec: ChatComposerStatusSpec): ChatCompose
146
149
  ...changes
147
150
  });
148
151
 
149
- if (sendBlockedReason) return status('blocked', {reason: sendBlockedReason});
152
+ // A send blocked while the server runs the turn can refresh to learn when it ends. A reply
153
+ // left running says so itself, so that block shows as the reply's status.
154
+ if (sendBlockedReason) {
155
+ const isRunOnServer = sendBlockedReason === RUN_ON_SERVER_REASON;
156
+ if (!isRunOnServer || replyStatus !== 'detached') {
157
+ return status('blocked', {
158
+ reason: sendBlockedReason,
159
+ action: isRunOnServer && canRefreshStatus ? 'refreshStatus' : null
160
+ });
161
+ }
162
+ }
150
163
  if (isOffline) return status('offline');
151
164
 
152
165
  switch (replyStatus) {
@@ -202,6 +215,13 @@ export function isChatRetrySafe(
202
215
  return !(historyMode === 'server' && KIT_UNSAFE_CODES.includes(errorCode));
203
216
  }
204
217
 
218
+ /**
219
+ * The reason `ChatThreadsModel` gives for a send it blocks while the server runs a turn that the
220
+ * chat does not follow.
221
+ * @internal
222
+ */
223
+ export const RUN_ON_SERVER_REASON = 'Wait for the reply to finish.';
224
+
205
225
  //------------------
206
226
  // Implementation
207
227
  //------------------
@@ -61,6 +61,35 @@ export function settleOpenParts(
61
61
  return settleParts(message, reason, isCancelled, endedAt);
62
62
  }
63
63
 
64
+ /**
65
+ * A copy of a message with the open tool calls that `isClosed` picks closed with `reason`, as
66
+ * failed - or denied if they awaited approval. Other parts stay as they are. Returns the same
67
+ * message if no call was picked.
68
+ *
69
+ * @param endedAt - the time to record on the calls it ends. Default now.
70
+ * @internal
71
+ */
72
+ export function settleOpenCalls(
73
+ message: ChatMessage,
74
+ isClosed: (call: ChatToolCallPart) => boolean,
75
+ reason: string,
76
+ endedAt: number = null
77
+ ): ChatMessage {
78
+ const calls = message.toolCalls.filter(it => !isToolCallFinal(it) && isClosed(it));
79
+ if (!calls.length) return message;
80
+ const settled = settleOpenParts(message.withChanges({parts: calls}), reason, false, endedAt),
81
+ byId = new Map(settled.parts.map(it => [it.id, it]));
82
+ return message.withChanges({parts: message.parts.map(it => byId.get(it.id) ?? it)});
83
+ }
84
+
85
+ /**
86
+ * True for a reply left running on the server - `aborted`, with `metadata.detached`.
87
+ * @internal
88
+ */
89
+ export function isDetachedReply(message: ChatMessage): boolean {
90
+ return message?.status === 'aborted' && !!message.metadata.detached;
91
+ }
92
+
64
93
  /**
65
94
  * Why `settleOpenParts` closed a tool call, by how its turn ended.
66
95
  * @internal
@@ -62,7 +62,8 @@ export interface ChatReplayOptions {
62
62
  * chat then offers to refresh its status, as after the user left a live reply running.
63
63
  *
64
64
  * Anything a turn leaves open, such as a tool call with no result, is closed as the live chat
65
- * would close it, at the time of the turn's last event. An interrupt that a later run answered
65
+ * would close it, at the time of the turn's last event. A detached turn's open tool calls and
66
+ * sub-agents stay open, as the server may still finish them. An interrupt that a later run answered
66
67
  * counts as delivered. The user's answers to it are not among the events.
67
68
  *
68
69
  * A record that the decoder rejects fails the reply, as it would fail a live stream, with the
@@ -98,12 +99,15 @@ export function replayChatEvents(
98
99
  isStopped = decodeError != null || opts.status === 'aborted' || !isEnded;
99
100
  message = isStopped ? reducer.settleText(message) : reducer.finish(message);
100
101
 
102
+ // A turn with no end goes on on the server, so its open calls and sub-agents stay open - but
103
+ // for the calls of its earlier steps, which ended with their runs.
101
104
  const status = decodeError ? 'error' : (opts.status ?? endStatus(message, reducer, isEnded)),
105
+ isDetached = !isEnded && opts.status == null && !decodeError,
102
106
  times = runs.map(runTimes).filter(it => it != null),
103
107
  endedAt = max(times.map(it => it[1])) ?? null,
104
108
  settleAt = endedAt ?? Date.now(),
105
109
  settled =
106
- status === 'paused'
110
+ status === 'paused' || isDetached
107
111
  ? settleStreamingParts(settleEarlierCalls(message, lastIdx, settleAt), settleAt)
108
112
  : settleOpenParts(message, SETTLE_REASONS[status], status === 'aborted', settleAt);
109
113
 
@@ -118,9 +122,7 @@ export function replayChatEvents(
118
122
  const elapsedMs = sumBy(times, ([start, end]) => end - start);
119
123
  replyMetadata = {...replyMetadata, elapsedMs, ...(status === 'paused' ? {} : {endedAt})};
120
124
  }
121
- if (!isEnded && opts.status == null && !decodeError) {
122
- replyMetadata = {...replyMetadata, detached: true};
123
- }
125
+ if (isDetached) replyMetadata = {...replyMetadata, detached: true};
124
126
  // The server took each answer that a later run carried.
125
127
  if (replyMetadata.interrupts) {
126
128
  replyMetadata = {
@@ -205,9 +207,9 @@ function splitRuns(events: ChatEvent[]): ChatEvent[][] {
205
207
  }
206
208
 
207
209
  /**
208
- * A paused reply with the tool calls of its earlier steps closed. A call the browser ran has its
209
- * result in the next step's request, not among the events, so it would otherwise wait on the user
210
- * again. Other parts stay as they are, such as a sub-agent that waits on the reply's interrupts.
210
+ * A paused or detached reply with the tool calls of its earlier steps closed. A call the browser
211
+ * ran has its result in the next step's request, not among the events, so it would otherwise stay
212
+ * open. Other parts stay as they are, such as a sub-agent that waits on the reply's interrupts.
211
213
  */
212
214
  function settleEarlierCalls(message: ChatMessage, step: number, endedAt: number): ChatMessage {
213
215
  const calls = message.toolCalls.filter(it => it.step < step),
@@ -348,6 +348,7 @@ function sendButton(
348
348
  key: 'stop',
349
349
  icon: Icon.stop({prefix: 'fas'}),
350
350
  text: 'Stop',
351
+ tooltip: 'Stop (Esc)',
351
352
  outlined: true,
352
353
  ...stopProps,
353
354
  className: classNames('xh-chat-composer__stop', stopProps.className),
@@ -84,6 +84,7 @@ import {
84
84
  } from './impl/groupParts';
85
85
  import {ChatSourceLinks, findSidePanel} from './impl/ChatSourceLinks';
86
86
  import {jsonView} from './impl/JsonView';
87
+ import {replyDetachedProvider} from './impl/ReplyDetached';
87
88
  import {useChatMarkdownComponents} from './ChatMarkdownComponents';
88
89
  import {formatFailureDetails, getFailureCause, isReplyNotSent} from './impl/ReplyFailure';
89
90
 
@@ -621,50 +622,58 @@ function assistantContent(
621
622
  className: 'xh-chat-message__main',
622
623
  items: [
623
624
  props.showHeader === false ? null : messageHeader(props, context),
624
- div({
625
- className: 'xh-chat-message__body',
626
- items: [
627
- ...withInterrupts(
628
- runs,
629
- message,
630
- chatModel,
631
- runs.map((run, idx) =>
632
- run.type === 'process'
633
- ? processRun(
634
- run,
635
- props,
636
- impl,
637
- context,
638
- references,
639
- markerSources
640
- )
641
- : run.type === 'timeline'
642
- ? timelineBlock(run, props, context, idx < runs.length - 1)
643
- : renderGroup(
644
- run,
645
- props,
646
- impl,
647
- context,
648
- references,
649
- markerSources
650
- )
651
- )
652
- ),
653
- // With no header to show its status, a reply that waits for its first
654
- // content shows a working cue in its body - after any answered questions.
655
- props.showHeader === false &&
656
- message.isStreaming &&
657
- isEmpty(runs) &&
658
- chatModel
659
- ? workingCue({
660
- key: 'working',
661
- message,
662
- chatModel,
663
- isLast: props.isLast
664
- })
665
- : null,
666
- replyNotice(props, chatModel)
667
- ]
625
+ replyDetachedProvider({
626
+ value: getChatReplyStatus({message}) === 'detached',
627
+ item: div({
628
+ className: 'xh-chat-message__body',
629
+ items: [
630
+ ...withInterrupts(
631
+ runs,
632
+ message,
633
+ chatModel,
634
+ runs.map((run, idx) =>
635
+ run.type === 'process'
636
+ ? processRun(
637
+ run,
638
+ props,
639
+ impl,
640
+ context,
641
+ references,
642
+ markerSources
643
+ )
644
+ : run.type === 'timeline'
645
+ ? timelineBlock(
646
+ run,
647
+ props,
648
+ context,
649
+ idx < runs.length - 1
650
+ )
651
+ : renderGroup(
652
+ run,
653
+ props,
654
+ impl,
655
+ context,
656
+ references,
657
+ markerSources
658
+ )
659
+ )
660
+ ),
661
+ // With no header to show its status, a reply that waits for its first
662
+ // content shows a working cue in its body - after any answered questions.
663
+ props.showHeader === false &&
664
+ message.isStreaming &&
665
+ isEmpty(runs) &&
666
+ chatModel
667
+ ? workingCue({
668
+ key: 'working',
669
+ message,
670
+ chatModel,
671
+ isLast: props.isLast
672
+ })
673
+ : null,
674
+ replyNotice(props, chatModel)
675
+ ]
676
+ })
668
677
  }),
669
678
  !isNoAnswer && references.length
670
679
  ? chatSources({sources: toSourceList(references), links: impl.sourceLinks})
@@ -82,10 +82,7 @@ export interface ChatPanelProps
82
82
  */
83
83
  messageListProps?: Omit<ChatMessageListProps, 'model' | 'modelConfig' | 'modelRef'>;
84
84
 
85
- /**
86
- * Props for the status region above the composer - for example `showStopHint`. Its
87
- * `assistantName` defaults to the message list's.
88
- */
85
+ /** Props for the status region above the composer. */
89
86
  statusRegionProps?: Omit<ChatStatusRegionProps, 'model' | 'modelConfig' | 'modelRef'>;
90
87
  }
91
88
 
@@ -177,7 +174,6 @@ export const [ChatPanel, chatPanel] = hoistCmp.withFactory<ChatPanelProps>({
177
174
  items: [
178
175
  chatStatusRegion({
179
176
  model: model.composerModel,
180
- assistantName: messageListProps?.assistantName,
181
177
  ...statusRegionProps
182
178
  }),
183
179
  chatComposer({
@@ -11,14 +11,6 @@
11
11
  min-width: 0;
12
12
  padding: 0 2px 0 var(--xh-chat-composer-status-inset);
13
13
 
14
- &--busy {
15
- color: var(--xh-chat-busy-color);
16
- }
17
-
18
- &--attention {
19
- color: var(--xh-chat-attention-color);
20
- }
21
-
22
14
  &--warning {
23
15
  color: var(--xh-chat-warning-color);
24
16
  }