@ahggg/dsh-side-chat 0.7.3 → 1.0.1

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 (80) hide show
  1. package/README.md +52 -20
  2. package/README.zh-CN.md +52 -20
  3. package/lib/client/apply.js +48 -32
  4. package/lib/client/contracts.d.ts +3 -58
  5. package/lib/client/dsh/ReadOnlyConversation.d.ts +10 -0
  6. package/lib/client/dsh/ReadOnlyConversation.js +99 -0
  7. package/lib/client/dsh/SideChatOverlay.d.ts +7 -0
  8. package/lib/client/{rc6/Rc6SideChatOverlay.js → dsh/SideChatOverlay.js} +26 -56
  9. package/lib/client/{rc6 → dsh}/context.d.ts +4 -6
  10. package/lib/client/dsh/conversation-store.d.ts +22 -0
  11. package/lib/client/dsh/conversation-store.js +19 -0
  12. package/lib/client/dsh/primitive-labels.d.ts +2 -0
  13. package/lib/client/dsh/primitive-labels.js +4 -0
  14. package/lib/client/{rc6 → dsh}/remote-adapter.d.ts +2 -2
  15. package/lib/client/dsh/remote-adapter.js +22 -0
  16. package/lib/client/dsh/sessions-adapter.d.ts +56 -0
  17. package/lib/client/dsh/sessions-adapter.js +185 -0
  18. package/lib/client/index.d.ts +2 -2
  19. package/lib/client/panel/SelectionQuote.js +8 -1
  20. package/lib/client/panel/SideChatErrorState.js +1 -1
  21. package/lib/client/panel/SideChatModelSelect.d.ts +2 -1
  22. package/lib/client/panel/SideChatModelSelect.js +43 -24
  23. package/lib/client/panel/SideChatPanel.js +12 -5
  24. package/lib/client/panel/messages.d.ts +1 -1
  25. package/lib/client/panel/messages.js +8 -8
  26. package/lib/client/parent-composer/ParentConversationAnnotations.d.ts +3 -2
  27. package/lib/client/parent-composer/ParentConversationAnnotations.js +50 -30
  28. package/lib/client/parent-composer/add-to-conversation.d.ts +14 -28
  29. package/lib/client/parent-composer/add-to-conversation.js +131 -545
  30. package/lib/client/parent-composer/annotation-persistence.d.ts +8 -8
  31. package/lib/client/parent-composer/annotation-persistence.js +106 -102
  32. package/lib/client/parent-composer/composer-reference.d.ts +17 -55
  33. package/lib/client/parent-composer/composer-reference.js +20 -72
  34. package/lib/client/parent-composer/referenced-conversation.d.ts +5 -7
  35. package/lib/client/parent-composer/referenced-conversation.js +21 -73
  36. package/lib/client/selection/ConversationAnnotationMarkers.js +5 -1
  37. package/lib/client/selection/SelectionActions.js +2 -1
  38. package/lib/client/selection/conversation-dom.d.ts +5 -0
  39. package/lib/client/selection/conversation-dom.js +24 -0
  40. package/lib/client/selection/selection-controller.d.ts +1 -1
  41. package/lib/client/selection/selection-controller.js +7 -3
  42. package/lib/client/selection/selection-normalizer.js +1 -1
  43. package/lib/client/side-chat-controller.d.ts +28 -41
  44. package/lib/client/side-chat-controller.js +247 -337
  45. package/lib/client.js +84 -54
  46. package/lib/host/index.d.ts +1 -1
  47. package/lib/host/index.js +1 -1
  48. package/lib/host/read-only-chat-service.d.ts +30 -0
  49. package/lib/host/read-only-chat-service.js +315 -0
  50. package/lib/index.d.ts +16 -8
  51. package/lib/index.js +34 -13
  52. package/lib/remote.d.ts +23 -14
  53. package/lib/remote.js +2 -5
  54. package/lib/shared/constants.d.ts +0 -13
  55. package/lib/shared/constants.js +0 -13
  56. package/lib/shared/contracts.d.ts +52 -19
  57. package/lib/shared/contracts.js +2 -3
  58. package/lib/shared/error-codes.d.ts +1 -1
  59. package/lib/shared/error-codes.js +2 -2
  60. package/lib/shared/{archived-wire.d.ts → side-chat-wire.d.ts} +109 -20
  61. package/lib/shared/side-chat-wire.js +34 -0
  62. package/lib/typert.d.ts +3 -68
  63. package/lib/typert.js +60 -4
  64. package/package.json +76 -56
  65. package/lib/client/rc6/ArchivedConversation.d.ts +0 -14
  66. package/lib/client/rc6/ArchivedConversation.js +0 -231
  67. package/lib/client/rc6/Rc6SideChatOverlay.d.ts +0 -7
  68. package/lib/client/rc6/SideChatTool.d.ts +0 -22
  69. package/lib/client/rc6/SideChatTool.js +0 -404
  70. package/lib/client/rc6/primitive-labels.d.ts +0 -7
  71. package/lib/client/rc6/primitive-labels.js +0 -60
  72. package/lib/client/rc6/remote-adapter.js +0 -34
  73. package/lib/client/rc6/runtime-compat.d.ts +0 -120
  74. package/lib/client/rc6/runtime-compat.js +0 -187
  75. package/lib/client/rc6/sessions-adapter.d.ts +0 -64
  76. package/lib/client/rc6/sessions-adapter.js +0 -387
  77. package/lib/host/archived-fork-service.d.ts +0 -28
  78. package/lib/host/archived-fork-service.js +0 -309
  79. package/lib/shared/archived-wire.js +0 -47
  80. /package/lib/client/{rc6 → dsh}/context.js +0 -0
@@ -1,37 +1,26 @@
1
1
  import { ObservableValue } from '../shared/observable.js';
2
- import { buildSideChatPrompt } from './parent-composer/add-to-conversation.js';
3
2
  import { assertSelectionCurrent } from './selection/selection-normalizer.js';
4
- const INITIAL_STATE = Object.freeze({ phase: 'closed', draft: '' });
5
- function success(value) {
6
- return { ok: true, value };
7
- }
8
- function failure(error) {
9
- return { ok: false, error };
10
- }
11
- function localError(code, message, recoverable = true) {
3
+ const INITIAL = Object.freeze({ phase: 'closed', draft: '', messages: [] });
4
+ const success = (value) => ({ ok: true, value });
5
+ const failure = (error) => ({ ok: false, error });
6
+ function localError(message, code = 'invalid_request', recoverable = false) {
12
7
  return { code, message, recoverable };
13
8
  }
14
- function phaseOf(snapshot) {
15
- switch (snapshot.status) {
16
- case 'idle': return 'ready';
17
- case 'running': return 'running';
18
- case 'needs-input': return 'needs-input';
19
- case 'needs-approval': return 'needs-approval';
20
- case 'failed': return 'error';
21
- case 'interrupted': return 'ready';
22
- }
23
- }
24
- /** Small controller for the stock rc.6 archived-fork path. */
9
+ /** Owns only plugin UI state and a cancellable model stream, never a child Session. */
25
10
  export class SideChatController {
26
11
  remote;
27
12
  sessions;
28
- observable = new ObservableValue(INITIAL_STATE, 'dsh-side-chat');
29
- lease;
30
- childUnsubscribe;
31
- opening;
32
- closing;
33
- closeRequested = false;
13
+ observable = new ObservableValue(INITIAL, 'dsh-side-chat');
14
+ generation = 0;
15
+ modelGeneration = 0;
16
+ requestSequence = 0;
34
17
  disposed = false;
18
+ releaseParent;
19
+ creating;
20
+ closing;
21
+ running;
22
+ cancelling = false;
23
+ pendingRequest;
35
24
  constructor(remote, sessions) {
36
25
  this.remote = remote;
37
26
  this.sessions = sessions;
@@ -40,306 +29,159 @@ export class SideChatController {
40
29
  subscribe = (listener) => this.observable.subscribe(listener);
41
30
  openDraft(input = {}) {
42
31
  if (this.disposed)
43
- return failure(localError('transport_error', 'The Side Chat controller is disposed.', false));
44
- if (this.getSnapshot().phase !== 'closed') {
45
- return failure(localError('side_chat_already_open', 'A Side Chat is already open.'));
46
- }
32
+ return failure(localError('Side Chat has been disposed.'));
33
+ if (this.getSnapshot().phase !== 'closed')
34
+ return failure(localError('Close the current Side Chat first.', 'side_chat_already_open'));
47
35
  const parentSessionId = input.parentSessionId ?? this.sessions.currentSessionId();
48
- if (parentSessionId === undefined) {
49
- return failure(localError('parent_session_missing', 'Start the main conversation first.'));
50
- }
36
+ if (parentSessionId === undefined)
37
+ return failure(localError('Start the main conversation first.', 'parent_session_missing'));
51
38
  try {
52
39
  if (input.selection !== undefined)
53
40
  assertSelectionCurrent(input.selection, parentSessionId);
41
+ this.releaseParent = this.sessions.retainParent(parentSessionId);
54
42
  }
55
- catch (error) {
56
- return failure(localError('selection_stale', error instanceof Error ? error.message : 'The selected text is no longer available.', false));
43
+ catch (cause) {
44
+ return failure(localError(cause instanceof Error ? cause.message : 'The parent conversation is unavailable.', 'selection_stale'));
57
45
  }
58
- const modelSelection = this.sessions.sideChatModelPreference();
59
- this.publish({
60
- phase: 'draft',
61
- parentSessionId,
62
- ...(input.selection === undefined ? {} : { selection: input.selection }),
63
- ...(modelSelection === undefined ? {} : { modelSelection }),
64
- draft: input.draft ?? '',
65
- });
46
+ ++this.generation;
47
+ this.observable.publish({ phase: 'draft', parentSessionId, selection: input.selection,
48
+ modelSelection: this.sessions.sideChatModelPreference(), draft: input.draft ?? '', messages: [] });
66
49
  return success(undefined);
67
50
  }
68
51
  setDraft(draft) {
69
52
  const state = this.getSnapshot();
70
- if (state.phase === 'closed' || state.phase === 'creating' || state.phase === 'opening'
71
- || state.phase === 'closing' || state.childSessionId !== undefined) {
72
- return failure(localError('invalid_request', 'The draft is not editable right now.', false));
53
+ if (!['draft', 'error', 'ready'].includes(state.phase) || state.messages.length > 0 || state.error?.operation === 'close') {
54
+ return failure(localError('The draft is not editable right now.'));
73
55
  }
74
- this.publish({ ...state, draft });
56
+ this.observable.publish({ ...state, draft });
75
57
  return success(undefined);
76
58
  }
77
59
  clearSelection() {
78
60
  const state = this.getSnapshot();
79
- if (state.selection === undefined)
80
- return success(undefined);
81
- if (state.childSessionId !== undefined || !['draft', 'error'].includes(state.phase)) {
82
- return failure(localError('invalid_request', 'The selected passage has already been sent.', false));
83
- }
84
- this.publish({ ...state, selection: undefined });
61
+ if (state.chatId !== undefined || !['draft', 'error'].includes(state.phase))
62
+ return failure(localError('The selected context is already captured.'));
63
+ this.observable.publish({ ...state, selection: undefined });
85
64
  return success(undefined);
86
65
  }
87
- initializeModel(selection) {
66
+ initializeModel(model) {
88
67
  const state = this.getSnapshot();
89
- if (state.childSessionId !== undefined || !['draft', 'error'].includes(state.phase)
90
- || state.error?.operation === 'close') {
91
- return failure(localError('invalid_request', 'The Side Chat model cannot be initialized right now.', false));
92
- }
93
- const selected = { ...selection };
94
- this.publish({ ...state, modelSelection: selected });
95
- return success(selected);
68
+ if (state.chatId !== undefined || !['draft', 'error'].includes(state.phase))
69
+ return failure(localError('The model cannot be initialized now.'));
70
+ this.observable.publish({ ...state, modelSelection: { ...model } });
71
+ return success({ ...model });
96
72
  }
97
- async selectModel(selection) {
73
+ async selectModel(model) {
98
74
  const state = this.getSnapshot();
99
- if (state.phase === 'closed' || ['creating', 'opening', 'closing'].includes(state.phase)
100
- || state.error?.operation === 'close') {
101
- return failure(localError('invalid_request', 'The Side Chat model cannot be changed right now.', false));
102
- }
103
- const selected = { ...selection };
104
- const childSessionId = state.childSessionId;
105
- if (childSessionId === undefined) {
106
- const initialized = this.initializeModel(selected);
107
- if (initialized.ok)
108
- this.sessions.rememberSideChatModelPreference(initialized.value);
109
- return initialized;
110
- }
111
- const result = await this.invoke(() => this.remote.selectModel({
112
- childSessionId,
113
- ...selected,
114
- }));
75
+ if (this.cancelling || !['draft', 'error', 'ready', 'running'].includes(state.phase) || state.error?.operation === 'close')
76
+ return failure(localError('The model cannot be changed while Side Chat is opening, stopping, or closing.'));
77
+ if (state.chatId === undefined) {
78
+ const result = this.initializeModel(model);
79
+ if (result.ok)
80
+ this.sessions.rememberSideChatModelPreference(result.value);
81
+ return result;
82
+ }
83
+ const generation = this.generation;
84
+ const version = ++this.modelGeneration;
85
+ const result = await this.invoke(() => this.remote.selectModel({ chatId: state.chatId, ...model }));
115
86
  if (!result.ok)
116
- return failure(result.error);
117
- const latest = this.getSnapshot();
118
- if (latest.childSessionId === childSessionId && !['closed', 'closing'].includes(latest.phase)) {
119
- this.publish({ ...latest, modelSelection: result.value.selected });
87
+ return result;
88
+ if (generation === this.generation && version === this.modelGeneration) {
89
+ this.observable.publish({ ...this.getSnapshot(), modelSelection: result.value.selected });
120
90
  this.sessions.rememberSideChatModelPreference(result.value.selected);
121
91
  }
122
92
  return success(result.value.selected);
123
93
  }
124
94
  async sendFirst(question) {
125
- if (this.opening !== undefined) {
126
- return failure(localError('invalid_request', 'The Side Chat is already opening.', false));
127
- }
128
95
  const state = this.getSnapshot();
129
- const trimmed = question.trim();
130
- if (trimmed.length === 0)
131
- return failure(localError('invalid_request', 'Enter a Side Chat question.', false));
132
- if (state.parentSessionId === undefined || !['draft', 'error'].includes(state.phase)) {
133
- return failure(localError('invalid_request', 'The first Side Chat message cannot be sent now.', false));
134
- }
135
- if (state.error?.operation === 'close') {
136
- return failure(localError('side_chat_destroy_failed', 'Retry closing the current Side Chat first.'));
137
- }
138
- if (state.childSessionId === undefined && state.selection !== undefined
139
- && !this.sessions.selectionIsCurrent(state.selection)) {
140
- const error = localError('selection_stale', 'Select the passage again before sending.', false);
141
- this.fail(error, 'create', { draft: trimmed, firstQuestion: trimmed });
142
- return failure(error);
143
- }
144
- const atSeq = state.selection?.atSeq ?? this.sessions.lastCompletedSeq(state.parentSessionId);
145
- if (atSeq === undefined) {
146
- const error = localError('parent_session_not_ready', 'Wait for a completed main-conversation turn first.');
147
- this.fail(error, 'create', { draft: trimmed, firstQuestion: trimmed });
148
- return failure(error);
149
- }
150
- const operation = this.createOpenAndPrompt(state.parentSessionId, atSeq, trimmed);
151
- this.opening = operation;
96
+ const text = question.trim();
97
+ if (text.length === 0 || state.parentSessionId === undefined || state.messages.length > 0
98
+ || !['draft', 'error', 'ready'].includes(state.phase) || state.error?.operation === 'close') {
99
+ return failure(localError('Enter a question in an open Side Chat draft.'));
100
+ }
101
+ if (state.chatId === undefined) {
102
+ if (state.selection !== undefined && !this.sessions.selectionIsCurrent(state.selection)) {
103
+ return this.fail(localError('Select the passage again before sending.', 'selection_stale'), 'create');
104
+ }
105
+ const atSeq = state.selection?.atSeq ?? this.sessions.lastCompletedSeq(state.parentSessionId);
106
+ if (atSeq === undefined)
107
+ return this.fail(localError('Wait for a completed parent turn.', 'parent_session_not_ready', true), 'create');
108
+ const generation = this.generation;
109
+ this.observable.publish({ ...state, phase: 'creating', draft: text, error: undefined });
110
+ const operation = this.invoke(() => this.remote.create({
111
+ parentSessionId: state.parentSessionId, atSeq,
112
+ selectedText: state.selection?.text, modelSelection: state.modelSelection,
113
+ }));
114
+ this.creating = operation;
115
+ const result = await operation;
116
+ if (this.creating === operation)
117
+ this.creating = undefined;
118
+ if (generation !== this.generation)
119
+ return failure(localError('The Side Chat was closed.'));
120
+ if (!result.ok)
121
+ return this.fail(result.error, 'create');
122
+ this.observable.publish({ ...this.getSnapshot(), phase: 'ready', chatId: result.value.chatId,
123
+ boundarySeq: result.value.boundarySeq, modelSelection: result.value.modelSelection, error: undefined });
124
+ this.sessions.rememberSideChatModelPreference(result.value.modelSelection);
125
+ }
126
+ return await this.send(text);
127
+ }
128
+ /** Resolves at admission; the independently consumed stream continues to update the transcript. */
129
+ async send(question) {
130
+ const state = this.getSnapshot();
131
+ const text = question.trim();
132
+ if (text.length === 0 || state.chatId === undefined || this.running !== undefined || this.cancelling
133
+ || !['ready', 'error'].includes(state.phase) || state.error?.operation === 'close') {
134
+ return failure(localError('Wait for the current reply or stop it before sending.'));
135
+ }
136
+ const request = this.pendingRequest?.chatId === state.chatId && this.pendingRequest.text === text
137
+ ? this.pendingRequest : { chatId: state.chatId, requestId: `${state.chatId}:${++this.requestSequence}`, text };
138
+ this.pendingRequest = request;
139
+ let stream;
152
140
  try {
153
- return await operation;
141
+ stream = this.remote.stream(request);
154
142
  }
155
- finally {
156
- if (this.opening === operation)
157
- this.opening = undefined;
143
+ catch (cause) {
144
+ return this.fail(localError(String(cause), 'transport_error', true), 'prompt');
158
145
  }
159
- }
160
- async send(text, mode = 'queue') {
161
- const trimmed = text.trim();
162
- if (trimmed.length === 0)
163
- return failure(localError('invalid_request', 'Enter a message.', false));
164
- return await this.sendParts([{ type: 'text', text: trimmed }], mode);
165
- }
166
- async sendParts(content, mode = 'queue') {
167
- if (content.length === 0)
168
- return failure(localError('invalid_request', 'Enter a message.', false));
169
- const binding = this.bindingTarget();
170
- if (!binding.ok)
171
- return binding;
172
- const result = await this.invoke(() => binding.value.prompt(content, mode));
173
- if (this.getSnapshot().phase === 'closing' || this.getSnapshot().phase === 'closed') {
174
- return failure(localError('transport_error', 'The Side Chat was closed.', false));
175
- }
176
- if (!result.ok)
177
- this.fail(result.error, 'prompt');
178
- return result.ok ? success(undefined) : failure(result.error);
179
- }
180
- async updateQueue(itemId, action) {
181
- const binding = this.bindingTarget();
182
- if (!binding.ok)
183
- return binding;
184
- const result = await this.invoke(() => binding.value.updateQueue(itemId, action));
185
- return result.ok ? success(undefined) : failure(result.error);
146
+ const active = { stream, generation: this.generation, request, cancelled: false };
147
+ this.running = active;
148
+ this.observable.publish({ ...state, phase: state.messages.length === 0 ? 'creating' : 'running', error: undefined });
149
+ const admission = new Promise(resolve => { active.done = this.consume(active, resolve); });
150
+ return await admission;
186
151
  }
187
152
  async cancel() {
188
- const binding = this.bindingTarget();
189
- if (!binding.ok)
190
- return binding;
191
- const result = await this.invoke(() => binding.value.cancel());
192
- return result.ok ? success(undefined) : failure(result.error);
193
- }
194
- async respondApproval(interactionId, decision) {
195
- const binding = this.bindingTarget();
196
- if (!binding.ok)
197
- return binding;
198
- const result = await this.invoke(() => binding.value.respondApproval(interactionId, decision));
199
- return result.ok ? success(undefined) : failure(result.error);
200
- }
201
- async respondQuestion(interactionId, answer) {
202
- const binding = this.bindingTarget();
203
- if (!binding.ok)
204
- return binding;
205
- const result = await this.invoke(() => binding.value.respondQuestion(interactionId, answer));
206
- return result.ok ? success(undefined) : failure(result.error);
207
- }
208
- async close() {
209
- const state = this.getSnapshot();
210
- if (state.phase === 'closed')
211
- return success(undefined);
212
- if (state.childSessionId === undefined && this.opening === undefined) {
213
- this.reset();
153
+ const chatId = this.getSnapshot().chatId;
154
+ if (chatId === undefined || this.running === undefined)
214
155
  return success(undefined);
156
+ this.cancelling = true;
157
+ const active = this.running;
158
+ active.cancelled = true;
159
+ this.disposeStream(active.stream);
160
+ try {
161
+ const result = await this.invoke(() => this.remote.cancel({ chatId }));
162
+ await active.done;
163
+ return result.ok ? success(undefined) : result;
215
164
  }
216
- if (this.opening !== undefined) {
217
- this.closeRequested = true;
218
- this.publish({ ...state, phase: 'closing', error: undefined });
219
- await this.opening;
220
- const after = this.getSnapshot();
221
- if (after.phase === 'closed')
222
- return success(undefined);
223
- if (after.error?.operation === 'close')
224
- return failure(after.error);
225
- if (after.childSessionId === undefined) {
226
- this.reset();
227
- return success(undefined);
228
- }
229
- return await this.closeChild(after.childSessionId);
230
- }
231
- if (state.childSessionId === undefined) {
232
- this.reset();
233
- return success(undefined);
165
+ finally {
166
+ this.cancelling = false;
234
167
  }
235
- return await this.closeChild(state.childSessionId);
236
168
  }
237
169
  async retry() {
238
170
  const state = this.getSnapshot();
239
171
  if (state.error?.operation === 'close')
240
172
  return await this.close();
241
- if (state.error?.operation === 'create'
242
- || state.error?.operation === 'open'
243
- || state.error?.operation === 'prompt') {
244
- return await this.sendFirst(state.firstQuestion ?? state.draft);
245
- }
246
- return failure(localError('invalid_request', 'There is no failed operation to retry.', false));
247
- }
248
- async dispose() {
249
- if (this.disposed)
250
- return;
251
- if (this.getSnapshot().phase !== 'closed') {
252
- const result = await this.close();
253
- if (!result.ok) {
254
- this.sessions.notify({ kind: 'warning', text: 'The Side Chat could not be closed cleanly.' });
255
- }
256
- }
257
- this.disposed = true;
258
- this.detachLease();
259
- this.observable.dispose();
260
- }
261
- async createOpenAndPrompt(parentSessionId, atSeq, question) {
262
- const original = this.getSnapshot();
263
- let childSessionId = original.childSessionId;
264
- if (childSessionId === undefined) {
265
- this.publish({ ...original, phase: 'creating', draft: question, firstQuestion: question, error: undefined });
266
- const created = await this.invoke(() => this.remote.create({
267
- parentSessionId,
268
- atSeq,
269
- ...(original.modelSelection === undefined ? {} : { modelSelection: original.modelSelection }),
270
- }));
271
- if (!created.ok) {
272
- if (this.closeRequested)
273
- this.reset();
274
- else
275
- this.fail(created.error, 'create', { draft: question, firstQuestion: question });
276
- return failure(created.error);
277
- }
278
- childSessionId = created.value.childSessionId;
279
- this.publishCreated(created.value, question);
280
- if (this.closeRequested || this.disposed) {
281
- const closed = await this.closeChild(childSessionId);
282
- return closed.ok
283
- ? failure(localError('transport_error', 'The Side Chat was closed.', false))
284
- : closed;
285
- }
286
- }
287
- if (this.lease === undefined) {
288
- this.publish({ ...this.getSnapshot(), phase: 'opening', error: undefined });
289
- try {
290
- const lease = await this.sessions.retain(childSessionId);
291
- if (this.closeRequested || this.disposed) {
292
- lease.release();
293
- const closed = await this.closeChild(childSessionId);
294
- return closed.ok
295
- ? failure(localError('transport_error', 'The Side Chat was closed.', false))
296
- : closed;
297
- }
298
- this.attachLease(lease, childSessionId);
299
- }
300
- catch {
301
- const error = localError('side_chat_open_failed', 'The child Session could not be opened.');
302
- this.fail(error, 'open', { firstQuestion: question, draft: question });
303
- return failure(error);
304
- }
305
- }
306
- const binding = this.lease?.binding;
307
- if (binding === undefined) {
308
- const error = localError('side_chat_open_failed', 'The child Session is unavailable.');
309
- this.fail(error, 'open', { firstQuestion: question, draft: question });
310
- return failure(error);
311
- }
312
- const prompted = await this.invoke(() => binding.prompt(buildSideChatPrompt(original.selection, question), 'queue'));
313
- if (!prompted.ok) {
314
- this.fail(prompted.error, 'prompt', { firstQuestion: question, draft: question });
315
- return failure(prompted.error);
316
- }
317
- this.publish({
318
- ...this.getSnapshot(),
319
- phase: phaseOf(binding.getSnapshot()),
320
- draft: '',
321
- firstQuestion: undefined,
322
- error: undefined,
323
- });
324
- return success(undefined);
325
- }
326
- publishCreated(created, question) {
327
- this.publish({
328
- ...this.getSnapshot(),
329
- phase: 'opening',
330
- parentSessionId: created.parentSessionId,
331
- childSessionId: created.childSessionId,
332
- boundarySeq: created.boundarySeq,
333
- inheritedThroughSeq: created.inheritedThroughSeq,
334
- ...(created.modelSelection === undefined ? {} : { modelSelection: created.modelSelection }),
335
- firstQuestion: question,
336
- error: undefined,
337
- });
173
+ if (state.messages.length === 0)
174
+ return await this.sendFirst(state.draft);
175
+ if (this.pendingRequest !== undefined)
176
+ return await this.send(this.pendingRequest.text);
177
+ return failure(localError('An admitted question is never replayed automatically. Send a follow-up to continue.'));
338
178
  }
339
- async closeChild(childSessionId) {
179
+ async close() {
340
180
  if (this.closing !== undefined)
341
181
  return await this.closing;
342
- const operation = this.performClose(childSessionId);
182
+ if (this.getSnapshot().phase === 'closed')
183
+ return success(undefined);
184
+ const operation = this.closeCurrent();
343
185
  this.closing = operation;
344
186
  try {
345
187
  return await operation;
@@ -349,74 +191,142 @@ export class SideChatController {
349
191
  this.closing = undefined;
350
192
  }
351
193
  }
352
- async performClose(childSessionId) {
353
- this.publish({ ...this.getSnapshot(), phase: 'closing', error: undefined });
354
- const closed = await this.invoke(() => this.remote.close({ childSessionId }));
355
- if (!closed.ok) {
356
- this.fail(closed.error, 'close');
357
- return failure(closed.error);
194
+ async dispose() {
195
+ if (this.disposed)
196
+ return;
197
+ this.disposed = true;
198
+ try {
199
+ const result = await this.close();
200
+ if (!result.ok)
201
+ this.sessions.notify({ kind: 'warning', text: result.error.message });
358
202
  }
359
- this.reset();
360
- return success(undefined);
361
- }
362
- bindingTarget() {
363
- const state = this.getSnapshot();
364
- const binding = this.lease?.binding;
365
- if (binding === undefined || state.childSessionId !== binding.sessionId
366
- || !['ready', 'running', 'needs-input', 'needs-approval'].includes(state.phase)) {
367
- return failure(localError('invalid_request', 'The Side Chat is not accepting messages.', false));
203
+ finally {
204
+ if (this.running !== undefined)
205
+ this.disposeStream(this.running.stream);
206
+ this.releaseParent?.();
207
+ this.releaseParent = undefined;
208
+ this.observable.dispose();
368
209
  }
369
- return success(binding);
370
210
  }
371
- attachLease(lease, expectedSessionId) {
372
- if (lease.sessionId !== expectedSessionId || lease.binding.sessionId !== expectedSessionId) {
373
- lease.release();
374
- throw new Error('The opened Session does not match the Side Chat child.');
211
+ async consume(active, resolve) {
212
+ let admitted = false;
213
+ let terminal = false;
214
+ const current = () => active.generation === this.generation && this.running === active;
215
+ const assistantId = `${active.request.requestId}:assistant`;
216
+ try {
217
+ for await (const event of active.stream) {
218
+ if (!current())
219
+ break;
220
+ const state = this.getSnapshot();
221
+ if (event.type === 'started') {
222
+ if (admitted || event.requestId !== active.request.requestId)
223
+ throw new Error('Invalid Side Chat admission response.');
224
+ admitted = true;
225
+ this.pendingRequest = undefined;
226
+ // Admission describes this turn, not the model/effort chosen for the
227
+ // next turn. A delayed admission or cached replay must not undo it.
228
+ this.observable.publish({ ...state, phase: 'running', draft: '',
229
+ messages: [...state.messages,
230
+ { id: `${active.request.requestId}:user`, role: 'user', text: active.request.text, status: 'complete',
231
+ selectedText: state.messages.length === 0 ? state.selection?.text : undefined },
232
+ { id: assistantId, role: 'assistant', text: '', reasoning: '', status: 'streaming' },
233
+ ], error: undefined });
234
+ resolve(success(undefined));
235
+ }
236
+ else if (event.type === 'content') {
237
+ if (!admitted)
238
+ throw new Error('Side Chat content arrived before admission.');
239
+ this.observable.publish({ ...state, messages: state.messages.map(message => message.id === assistantId
240
+ ? { ...message, text: event.text, reasoning: event.reasoning } : message) });
241
+ }
242
+ else if (event.type === 'finished') {
243
+ if (!admitted)
244
+ throw new Error('Side Chat ended before admission.');
245
+ terminal = true;
246
+ this.observable.publish({ ...state, phase: 'ready', messages: state.messages.map(message => message.id === assistantId
247
+ ? { ...message, status: event.status } : message) });
248
+ break;
249
+ }
250
+ else {
251
+ terminal = true;
252
+ const issue = { ...event.error, recoverable: !admitted && event.error.recoverable };
253
+ this.streamFailure(issue, assistantId);
254
+ resolve(failure(issue));
255
+ break;
256
+ }
257
+ }
258
+ if (current() && !terminal && !active.cancelled)
259
+ throw new Error('The Side Chat connection ended before the reply completed.');
260
+ }
261
+ catch (cause) {
262
+ if (current() && !active.cancelled) {
263
+ const issue = localError(cause instanceof Error ? cause.message : String(cause), 'transport_error', !admitted);
264
+ this.streamFailure(issue, assistantId);
265
+ resolve(failure(issue));
266
+ }
267
+ }
268
+ finally {
269
+ if (current() && active.cancelled) {
270
+ this.pendingRequest = undefined;
271
+ const state = this.getSnapshot();
272
+ this.observable.publish({ ...state, phase: 'ready', error: undefined,
273
+ messages: state.messages.map(message => message.id === assistantId ? { ...message, status: 'stopped' } : message) });
274
+ }
275
+ this.disposeStream(active.stream);
276
+ if (this.running === active)
277
+ this.running = undefined;
278
+ resolve(failure(localError('The Side Chat was closed.')));
375
279
  }
376
- this.detachLease();
377
- this.lease = lease;
378
- this.childUnsubscribe = lease.binding.subscribe(() => { this.updateFromChild(); });
379
- this.updateFromChild();
380
280
  }
381
- detachLease() {
382
- this.childUnsubscribe?.();
383
- this.childUnsubscribe = undefined;
384
- this.lease?.release();
385
- this.lease = undefined;
281
+ streamFailure(issue, assistantId) {
282
+ const state = this.getSnapshot();
283
+ this.observable.publish({ ...state, phase: state.messages.length === 0 ? 'error' : 'ready',
284
+ messages: state.messages.map(message => message.id === assistantId ? { ...message, status: 'error' } : message),
285
+ error: { ...issue, operation: 'prompt' } });
386
286
  }
387
- updateFromChild() {
287
+ async closeCurrent() {
388
288
  const state = this.getSnapshot();
389
- const snapshot = this.lease?.binding.getSnapshot();
390
- if (snapshot === undefined || ['closed', 'closing', 'error'].includes(state.phase))
391
- return;
392
- if (snapshot.status === 'failed') {
393
- this.fail(localError('side_chat_prompt_failed', 'The Side Chat turn failed.'), 'prompt');
394
- return;
289
+ const creating = this.creating;
290
+ ++this.generation;
291
+ if (this.running !== undefined)
292
+ this.disposeStream(this.running.stream);
293
+ this.running = undefined;
294
+ this.observable.publish({ ...state, phase: 'closing', error: undefined });
295
+ let chatId = state.chatId;
296
+ if (creating !== undefined) {
297
+ const created = await creating;
298
+ if (created.ok)
299
+ chatId = created.value.chatId;
300
+ }
301
+ if (chatId !== undefined) {
302
+ const result = await this.invoke(() => this.remote.close({ chatId: chatId }));
303
+ if (!result.ok && result.error.code !== 'side_chat_not_found') {
304
+ this.observable.publish({ ...this.getSnapshot(), phase: 'error', chatId, error: { ...result.error, operation: 'close' } });
305
+ return result;
306
+ }
395
307
  }
396
- this.publish({ ...state, phase: phaseOf(snapshot), error: undefined });
308
+ this.pendingRequest = undefined;
309
+ this.releaseParent?.();
310
+ this.releaseParent = undefined;
311
+ this.observable.publish(INITIAL);
312
+ return success(undefined);
397
313
  }
398
- fail(error, operation, patch = {}) {
399
- this.publish({
400
- ...this.getSnapshot(),
401
- ...patch,
402
- phase: 'error',
403
- error: { ...error, operation },
404
- });
314
+ fail(issue, operation) {
315
+ this.observable.publish({ ...this.getSnapshot(), phase: 'error', error: { ...issue, operation } });
316
+ return failure(issue);
317
+ }
318
+ disposeStream(stream) {
319
+ try {
320
+ stream.dispose();
321
+ }
322
+ catch { /* A broken carrier must not prevent the explicit Host cancel/close RPC or parent release. */ }
405
323
  }
406
324
  async invoke(operation) {
407
325
  try {
408
326
  return await operation();
409
327
  }
410
- catch {
411
- return { ok: false, error: localError('transport_error', 'The Side Chat connection was interrupted.') };
328
+ catch (cause) {
329
+ return failure(localError(cause instanceof Error ? cause.message : String(cause), 'transport_error', true));
412
330
  }
413
331
  }
414
- reset() {
415
- this.detachLease();
416
- this.closeRequested = false;
417
- this.publish(INITIAL_STATE);
418
- }
419
- publish(state) {
420
- this.observable.publish(Object.freeze(state));
421
- }
422
332
  }