@ahggg/dsh-side-chat 0.7.2 → 1.0.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 (185) hide show
  1. package/README.md +16 -13
  2. package/README.zh-CN.md +16 -13
  3. package/lib/client/apply.d.ts +0 -1
  4. package/lib/client/apply.js +48 -33
  5. package/lib/client/contracts.d.ts +3 -59
  6. package/lib/client/contracts.js +0 -1
  7. package/lib/client/dsh/ReadOnlyConversation.d.ts +10 -0
  8. package/lib/client/dsh/ReadOnlyConversation.js +91 -0
  9. package/lib/client/dsh/SideChatOverlay.d.ts +7 -0
  10. package/lib/client/{rc6/Rc6SideChatOverlay.js → dsh/SideChatOverlay.js} +26 -57
  11. package/lib/client/{rc6 → dsh}/context.d.ts +7 -3
  12. package/lib/client/dsh/context.js +1 -0
  13. package/lib/client/dsh/conversation-store.d.ts +22 -0
  14. package/lib/client/dsh/conversation-store.js +19 -0
  15. package/lib/client/dsh/primitive-labels.d.ts +2 -0
  16. package/lib/client/dsh/primitive-labels.js +4 -0
  17. package/lib/client/{rc6 → dsh}/remote-adapter.d.ts +2 -3
  18. package/lib/client/dsh/remote-adapter.js +22 -0
  19. package/lib/client/dsh/sessions-adapter.d.ts +56 -0
  20. package/lib/client/dsh/sessions-adapter.js +185 -0
  21. package/lib/client/index.d.ts +2 -3
  22. package/lib/client/index.js +0 -1
  23. package/lib/client/model-preference.d.ts +0 -1
  24. package/lib/client/model-preference.js +0 -1
  25. package/lib/client/panel/SelectionQuote.d.ts +0 -1
  26. package/lib/client/panel/SelectionQuote.js +8 -2
  27. package/lib/client/panel/SendIcon.d.ts +0 -1
  28. package/lib/client/panel/SendIcon.js +0 -1
  29. package/lib/client/panel/SideChatBody.d.ts +0 -1
  30. package/lib/client/panel/SideChatBody.js +0 -1
  31. package/lib/client/panel/SideChatErrorState.d.ts +0 -1
  32. package/lib/client/panel/SideChatErrorState.js +1 -2
  33. package/lib/client/panel/SideChatHeader.d.ts +0 -1
  34. package/lib/client/panel/SideChatHeader.js +0 -1
  35. package/lib/client/panel/SideChatModelSelect.d.ts +0 -1
  36. package/lib/client/panel/SideChatModelSelect.js +37 -19
  37. package/lib/client/panel/SideChatPanel.d.ts +0 -1
  38. package/lib/client/panel/SideChatPanel.js +12 -6
  39. package/lib/client/panel/StopIcon.d.ts +0 -1
  40. package/lib/client/panel/StopIcon.js +0 -1
  41. package/lib/client/panel/messages.d.ts +1 -2
  42. package/lib/client/panel/messages.js +8 -9
  43. package/lib/client/panel/use-auto-growing-textarea.d.ts +0 -1
  44. package/lib/client/panel/use-auto-growing-textarea.js +0 -1
  45. package/lib/client/parent-composer/ParentConversationAnnotations.d.ts +3 -3
  46. package/lib/client/parent-composer/ParentConversationAnnotations.js +71 -28
  47. package/lib/client/parent-composer/add-to-conversation.d.ts +14 -29
  48. package/lib/client/parent-composer/add-to-conversation.js +134 -457
  49. package/lib/client/parent-composer/annotation-persistence.d.ts +8 -9
  50. package/lib/client/parent-composer/annotation-persistence.js +106 -103
  51. package/lib/client/parent-composer/composer-reference.d.ts +19 -48
  52. package/lib/client/parent-composer/composer-reference.js +26 -46
  53. package/lib/client/parent-composer/referenced-conversation.d.ts +5 -8
  54. package/lib/client/parent-composer/referenced-conversation.js +27 -61
  55. package/lib/client/selection/ConversationAnnotationMarkers.d.ts +0 -1
  56. package/lib/client/selection/ConversationAnnotationMarkers.js +5 -2
  57. package/lib/client/selection/SelectionActions.d.ts +0 -1
  58. package/lib/client/selection/SelectionActions.js +2 -2
  59. package/lib/client/selection/conversation-dom.d.ts +5 -0
  60. package/lib/client/selection/conversation-dom.js +24 -0
  61. package/lib/client/selection/selection-controller.d.ts +1 -2
  62. package/lib/client/selection/selection-controller.js +7 -4
  63. package/lib/client/selection/selection-limits.d.ts +0 -1
  64. package/lib/client/selection/selection-limits.js +0 -1
  65. package/lib/client/selection/selection-normalizer.d.ts +0 -1
  66. package/lib/client/selection/selection-normalizer.js +1 -2
  67. package/lib/client/side-chat-controller.d.ts +28 -42
  68. package/lib/client/side-chat-controller.js +245 -338
  69. package/lib/client.js +1202 -8951
  70. package/lib/host/index.d.ts +1 -2
  71. package/lib/host/index.js +1 -2
  72. package/lib/host/read-only-chat-service.d.ts +30 -0
  73. package/lib/host/read-only-chat-service.js +314 -0
  74. package/lib/index.d.ts +16 -9
  75. package/lib/index.js +34 -14
  76. package/lib/remote.d.ts +23 -15
  77. package/lib/remote.js +2 -6
  78. package/lib/shared/constants.d.ts +0 -14
  79. package/lib/shared/constants.js +0 -14
  80. package/lib/shared/contracts.d.ts +52 -20
  81. package/lib/shared/contracts.js +2 -4
  82. package/lib/shared/error-codes.d.ts +1 -2
  83. package/lib/shared/error-codes.js +2 -3
  84. package/lib/shared/observable.d.ts +0 -1
  85. package/lib/shared/observable.js +0 -1
  86. package/lib/shared/{archived-wire.d.ts → side-chat-wire.d.ts} +109 -21
  87. package/lib/shared/side-chat-wire.js +34 -0
  88. package/lib/typert.d.ts +3 -69
  89. package/lib/typert.js +120 -51
  90. package/package.json +80 -38
  91. package/lib/client/apply.d.ts.map +0 -1
  92. package/lib/client/apply.js.map +0 -1
  93. package/lib/client/contracts.d.ts.map +0 -1
  94. package/lib/client/contracts.js.map +0 -1
  95. package/lib/client/index.d.ts.map +0 -1
  96. package/lib/client/index.js.map +0 -1
  97. package/lib/client/model-preference.d.ts.map +0 -1
  98. package/lib/client/model-preference.js.map +0 -1
  99. package/lib/client/panel/SelectionQuote.d.ts.map +0 -1
  100. package/lib/client/panel/SelectionQuote.js.map +0 -1
  101. package/lib/client/panel/SendIcon.d.ts.map +0 -1
  102. package/lib/client/panel/SendIcon.js.map +0 -1
  103. package/lib/client/panel/SideChatBody.d.ts.map +0 -1
  104. package/lib/client/panel/SideChatBody.js.map +0 -1
  105. package/lib/client/panel/SideChatErrorState.d.ts.map +0 -1
  106. package/lib/client/panel/SideChatErrorState.js.map +0 -1
  107. package/lib/client/panel/SideChatHeader.d.ts.map +0 -1
  108. package/lib/client/panel/SideChatHeader.js.map +0 -1
  109. package/lib/client/panel/SideChatModelSelect.d.ts.map +0 -1
  110. package/lib/client/panel/SideChatModelSelect.js.map +0 -1
  111. package/lib/client/panel/SideChatPanel.d.ts.map +0 -1
  112. package/lib/client/panel/SideChatPanel.js.map +0 -1
  113. package/lib/client/panel/StopIcon.d.ts.map +0 -1
  114. package/lib/client/panel/StopIcon.js.map +0 -1
  115. package/lib/client/panel/messages.d.ts.map +0 -1
  116. package/lib/client/panel/messages.js.map +0 -1
  117. package/lib/client/panel/use-auto-growing-textarea.d.ts.map +0 -1
  118. package/lib/client/panel/use-auto-growing-textarea.js.map +0 -1
  119. package/lib/client/parent-composer/ParentConversationAnnotations.d.ts.map +0 -1
  120. package/lib/client/parent-composer/ParentConversationAnnotations.js.map +0 -1
  121. package/lib/client/parent-composer/add-to-conversation.d.ts.map +0 -1
  122. package/lib/client/parent-composer/add-to-conversation.js.map +0 -1
  123. package/lib/client/parent-composer/annotation-persistence.d.ts.map +0 -1
  124. package/lib/client/parent-composer/annotation-persistence.js.map +0 -1
  125. package/lib/client/parent-composer/composer-reference.d.ts.map +0 -1
  126. package/lib/client/parent-composer/composer-reference.js.map +0 -1
  127. package/lib/client/parent-composer/referenced-conversation.d.ts.map +0 -1
  128. package/lib/client/parent-composer/referenced-conversation.js.map +0 -1
  129. package/lib/client/rc6/ArchivedConversation.d.ts +0 -15
  130. package/lib/client/rc6/ArchivedConversation.d.ts.map +0 -1
  131. package/lib/client/rc6/ArchivedConversation.js +0 -230
  132. package/lib/client/rc6/ArchivedConversation.js.map +0 -1
  133. package/lib/client/rc6/Rc6SideChatOverlay.d.ts +0 -8
  134. package/lib/client/rc6/Rc6SideChatOverlay.d.ts.map +0 -1
  135. package/lib/client/rc6/Rc6SideChatOverlay.js.map +0 -1
  136. package/lib/client/rc6/SideChatTool.d.ts +0 -23
  137. package/lib/client/rc6/SideChatTool.d.ts.map +0 -1
  138. package/lib/client/rc6/SideChatTool.js +0 -395
  139. package/lib/client/rc6/SideChatTool.js.map +0 -1
  140. package/lib/client/rc6/context.d.ts.map +0 -1
  141. package/lib/client/rc6/context.js +0 -2
  142. package/lib/client/rc6/context.js.map +0 -1
  143. package/lib/client/rc6/remote-adapter.d.ts.map +0 -1
  144. package/lib/client/rc6/remote-adapter.js +0 -33
  145. package/lib/client/rc6/remote-adapter.js.map +0 -1
  146. package/lib/client/rc6/sessions-adapter.d.ts +0 -61
  147. package/lib/client/rc6/sessions-adapter.d.ts.map +0 -1
  148. package/lib/client/rc6/sessions-adapter.js +0 -370
  149. package/lib/client/rc6/sessions-adapter.js.map +0 -1
  150. package/lib/client/selection/ConversationAnnotationMarkers.d.ts.map +0 -1
  151. package/lib/client/selection/ConversationAnnotationMarkers.js.map +0 -1
  152. package/lib/client/selection/SelectionActions.d.ts.map +0 -1
  153. package/lib/client/selection/SelectionActions.js.map +0 -1
  154. package/lib/client/selection/selection-controller.d.ts.map +0 -1
  155. package/lib/client/selection/selection-controller.js.map +0 -1
  156. package/lib/client/selection/selection-limits.d.ts.map +0 -1
  157. package/lib/client/selection/selection-limits.js.map +0 -1
  158. package/lib/client/selection/selection-normalizer.d.ts.map +0 -1
  159. package/lib/client/selection/selection-normalizer.js.map +0 -1
  160. package/lib/client/side-chat-controller.d.ts.map +0 -1
  161. package/lib/client/side-chat-controller.js.map +0 -1
  162. package/lib/client.js.map +0 -1
  163. package/lib/host/archived-fork-service.d.ts +0 -29
  164. package/lib/host/archived-fork-service.d.ts.map +0 -1
  165. package/lib/host/archived-fork-service.js +0 -264
  166. package/lib/host/archived-fork-service.js.map +0 -1
  167. package/lib/host/index.d.ts.map +0 -1
  168. package/lib/host/index.js.map +0 -1
  169. package/lib/index.d.ts.map +0 -1
  170. package/lib/index.js.map +0 -1
  171. package/lib/remote.d.ts.map +0 -1
  172. package/lib/remote.js.map +0 -1
  173. package/lib/shared/archived-wire.d.ts.map +0 -1
  174. package/lib/shared/archived-wire.js +0 -48
  175. package/lib/shared/archived-wire.js.map +0 -1
  176. package/lib/shared/constants.d.ts.map +0 -1
  177. package/lib/shared/constants.js.map +0 -1
  178. package/lib/shared/contracts.d.ts.map +0 -1
  179. package/lib/shared/contracts.js.map +0 -1
  180. package/lib/shared/error-codes.d.ts.map +0 -1
  181. package/lib/shared/error-codes.js.map +0 -1
  182. package/lib/shared/observable.d.ts.map +0 -1
  183. package/lib/shared/observable.js.map +0 -1
  184. package/lib/typert.d.ts.map +0 -1
  185. package/lib/typert.js.map +0 -1
@@ -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,158 @@ 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'].includes(state.phase) || state.error?.operation === 'close')
76
+ return failure(localError('Stop the reply before changing models.'));
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 && this.getSnapshot().phase !== 'running') {
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
+ }
125
+ return await this.send(text);
126
+ }
127
+ /** Resolves at admission; the independently consumed stream continues to update the transcript. */
128
+ async send(question) {
129
+ const state = this.getSnapshot();
130
+ const text = question.trim();
131
+ if (text.length === 0 || state.chatId === undefined || this.running !== undefined || this.cancelling
132
+ || !['ready', 'error'].includes(state.phase) || state.error?.operation === 'close') {
133
+ return failure(localError('Wait for the current reply or stop it before sending.'));
134
+ }
135
+ const request = this.pendingRequest?.chatId === state.chatId && this.pendingRequest.text === text
136
+ ? this.pendingRequest : { chatId: state.chatId, requestId: `${state.chatId}:${++this.requestSequence}`, text };
137
+ this.pendingRequest = request;
138
+ let stream;
152
139
  try {
153
- return await operation;
140
+ stream = this.remote.stream(request);
154
141
  }
155
- finally {
156
- if (this.opening === operation)
157
- this.opening = undefined;
142
+ catch (cause) {
143
+ return this.fail(localError(String(cause), 'transport_error', true), 'prompt');
158
144
  }
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);
145
+ const active = { stream, generation: this.generation, request, cancelled: false };
146
+ this.running = active;
147
+ this.observable.publish({ ...state, phase: state.messages.length === 0 ? 'creating' : 'running', error: undefined });
148
+ const admission = new Promise(resolve => { active.done = this.consume(active, resolve); });
149
+ return await admission;
186
150
  }
187
151
  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();
152
+ const chatId = this.getSnapshot().chatId;
153
+ if (chatId === undefined || this.running === undefined)
214
154
  return success(undefined);
155
+ this.cancelling = true;
156
+ const active = this.running;
157
+ active.cancelled = true;
158
+ this.disposeStream(active.stream);
159
+ try {
160
+ const result = await this.invoke(() => this.remote.cancel({ chatId }));
161
+ await active.done;
162
+ return result.ok ? success(undefined) : result;
215
163
  }
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);
164
+ finally {
165
+ this.cancelling = false;
234
166
  }
235
- return await this.closeChild(state.childSessionId);
236
167
  }
237
168
  async retry() {
238
169
  const state = this.getSnapshot();
239
170
  if (state.error?.operation === 'close')
240
171
  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
- });
172
+ if (state.messages.length === 0)
173
+ return await this.sendFirst(state.draft);
174
+ if (this.pendingRequest !== undefined)
175
+ return await this.send(this.pendingRequest.text);
176
+ return failure(localError('An admitted question is never replayed automatically. Send a follow-up to continue.'));
338
177
  }
339
- async closeChild(childSessionId) {
178
+ async close() {
340
179
  if (this.closing !== undefined)
341
180
  return await this.closing;
342
- const operation = this.performClose(childSessionId);
181
+ if (this.getSnapshot().phase === 'closed')
182
+ return success(undefined);
183
+ const operation = this.closeCurrent();
343
184
  this.closing = operation;
344
185
  try {
345
186
  return await operation;
@@ -349,75 +190,141 @@ export class SideChatController {
349
190
  this.closing = undefined;
350
191
  }
351
192
  }
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);
193
+ async dispose() {
194
+ if (this.disposed)
195
+ return;
196
+ this.disposed = true;
197
+ try {
198
+ const result = await this.close();
199
+ if (!result.ok)
200
+ this.sessions.notify({ kind: 'warning', text: result.error.message });
358
201
  }
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));
202
+ finally {
203
+ if (this.running !== undefined)
204
+ this.disposeStream(this.running.stream);
205
+ this.releaseParent?.();
206
+ this.releaseParent = undefined;
207
+ this.observable.dispose();
368
208
  }
369
- return success(binding);
370
209
  }
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.');
210
+ async consume(active, resolve) {
211
+ let admitted = false;
212
+ let terminal = false;
213
+ const current = () => active.generation === this.generation && this.running === active;
214
+ const assistantId = `${active.request.requestId}:assistant`;
215
+ try {
216
+ for await (const event of active.stream) {
217
+ if (!current())
218
+ break;
219
+ const state = this.getSnapshot();
220
+ if (event.type === 'started') {
221
+ if (admitted || event.requestId !== active.request.requestId)
222
+ throw new Error('Invalid Side Chat admission response.');
223
+ admitted = true;
224
+ this.pendingRequest = undefined;
225
+ this.observable.publish({ ...state, phase: 'running', draft: '', modelSelection: event.modelSelection,
226
+ messages: [...state.messages,
227
+ { id: `${active.request.requestId}:user`, role: 'user', text: active.request.text, status: 'complete',
228
+ selectedText: state.messages.length === 0 ? state.selection?.text : undefined },
229
+ { id: assistantId, role: 'assistant', text: '', reasoning: '', status: 'streaming' },
230
+ ], error: undefined });
231
+ this.sessions.rememberSideChatModelPreference(event.modelSelection);
232
+ resolve(success(undefined));
233
+ }
234
+ else if (event.type === 'content') {
235
+ if (!admitted)
236
+ throw new Error('Side Chat content arrived before admission.');
237
+ this.observable.publish({ ...state, messages: state.messages.map(message => message.id === assistantId
238
+ ? { ...message, text: event.text, reasoning: event.reasoning } : message) });
239
+ }
240
+ else if (event.type === 'finished') {
241
+ if (!admitted)
242
+ throw new Error('Side Chat ended before admission.');
243
+ terminal = true;
244
+ this.observable.publish({ ...state, phase: 'ready', messages: state.messages.map(message => message.id === assistantId
245
+ ? { ...message, status: event.status } : message) });
246
+ break;
247
+ }
248
+ else {
249
+ terminal = true;
250
+ const issue = { ...event.error, recoverable: !admitted && event.error.recoverable };
251
+ this.streamFailure(issue, assistantId);
252
+ resolve(failure(issue));
253
+ break;
254
+ }
255
+ }
256
+ if (current() && !terminal && !active.cancelled)
257
+ throw new Error('The Side Chat connection ended before the reply completed.');
258
+ }
259
+ catch (cause) {
260
+ if (current() && !active.cancelled) {
261
+ const issue = localError(cause instanceof Error ? cause.message : String(cause), 'transport_error', !admitted);
262
+ this.streamFailure(issue, assistantId);
263
+ resolve(failure(issue));
264
+ }
265
+ }
266
+ finally {
267
+ if (current() && active.cancelled) {
268
+ this.pendingRequest = undefined;
269
+ const state = this.getSnapshot();
270
+ this.observable.publish({ ...state, phase: 'ready', error: undefined,
271
+ messages: state.messages.map(message => message.id === assistantId ? { ...message, status: 'stopped' } : message) });
272
+ }
273
+ this.disposeStream(active.stream);
274
+ if (this.running === active)
275
+ this.running = undefined;
276
+ resolve(failure(localError('The Side Chat was closed.')));
375
277
  }
376
- this.detachLease();
377
- this.lease = lease;
378
- this.childUnsubscribe = lease.binding.subscribe(() => { this.updateFromChild(); });
379
- this.updateFromChild();
380
278
  }
381
- detachLease() {
382
- this.childUnsubscribe?.();
383
- this.childUnsubscribe = undefined;
384
- this.lease?.release();
385
- this.lease = undefined;
279
+ streamFailure(issue, assistantId) {
280
+ const state = this.getSnapshot();
281
+ this.observable.publish({ ...state, phase: state.messages.length === 0 ? 'error' : 'ready',
282
+ messages: state.messages.map(message => message.id === assistantId ? { ...message, status: 'error' } : message),
283
+ error: { ...issue, operation: 'prompt' } });
386
284
  }
387
- updateFromChild() {
285
+ async closeCurrent() {
388
286
  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;
287
+ const creating = this.creating;
288
+ ++this.generation;
289
+ if (this.running !== undefined)
290
+ this.disposeStream(this.running.stream);
291
+ this.running = undefined;
292
+ this.observable.publish({ ...state, phase: 'closing', error: undefined });
293
+ let chatId = state.chatId;
294
+ if (creating !== undefined) {
295
+ const created = await creating;
296
+ if (created.ok)
297
+ chatId = created.value.chatId;
298
+ }
299
+ if (chatId !== undefined) {
300
+ const result = await this.invoke(() => this.remote.close({ chatId: chatId }));
301
+ if (!result.ok && result.error.code !== 'side_chat_not_found') {
302
+ this.observable.publish({ ...this.getSnapshot(), phase: 'error', chatId, error: { ...result.error, operation: 'close' } });
303
+ return result;
304
+ }
395
305
  }
396
- this.publish({ ...state, phase: phaseOf(snapshot), error: undefined });
306
+ this.pendingRequest = undefined;
307
+ this.releaseParent?.();
308
+ this.releaseParent = undefined;
309
+ this.observable.publish(INITIAL);
310
+ return success(undefined);
397
311
  }
398
- fail(error, operation, patch = {}) {
399
- this.publish({
400
- ...this.getSnapshot(),
401
- ...patch,
402
- phase: 'error',
403
- error: { ...error, operation },
404
- });
312
+ fail(issue, operation) {
313
+ this.observable.publish({ ...this.getSnapshot(), phase: 'error', error: { ...issue, operation } });
314
+ return failure(issue);
315
+ }
316
+ disposeStream(stream) {
317
+ try {
318
+ stream.dispose();
319
+ }
320
+ catch { /* A broken carrier must not prevent the explicit Host cancel/close RPC or parent release. */ }
405
321
  }
406
322
  async invoke(operation) {
407
323
  try {
408
324
  return await operation();
409
325
  }
410
- catch {
411
- return { ok: false, error: localError('transport_error', 'The Side Chat connection was interrupted.') };
326
+ catch (cause) {
327
+ return failure(localError(cause instanceof Error ? cause.message : String(cause), 'transport_error', true));
412
328
  }
413
329
  }
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
330
  }
423
- //# sourceMappingURL=side-chat-controller.js.map