@tetherto/mdk-ui-agent 0.0.0 → 0.12.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 (217) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +310 -0
  3. package/dist/branding.d.ts +36 -0
  4. package/dist/branding.d.ts.map +1 -0
  5. package/dist/branding.js +37 -0
  6. package/dist/branding.js.map +1 -0
  7. package/dist/components/approval-card.d.ts +46 -0
  8. package/dist/components/approval-card.d.ts.map +1 -0
  9. package/dist/components/approval-card.js +84 -0
  10. package/dist/components/approval-card.js.map +1 -0
  11. package/dist/components/assistant-message.d.ts +25 -0
  12. package/dist/components/assistant-message.d.ts.map +1 -0
  13. package/dist/components/assistant-message.js +49 -0
  14. package/dist/components/assistant-message.js.map +1 -0
  15. package/dist/components/co-pilot-panel.d.ts +17 -0
  16. package/dist/components/co-pilot-panel.d.ts.map +1 -0
  17. package/dist/components/co-pilot-panel.js +33 -0
  18. package/dist/components/co-pilot-panel.js.map +1 -0
  19. package/dist/components/code-block.d.ts +17 -0
  20. package/dist/components/code-block.d.ts.map +1 -0
  21. package/dist/components/code-block.js +40 -0
  22. package/dist/components/code-block.js.map +1 -0
  23. package/dist/components/collapsible-answer.d.ts +26 -0
  24. package/dist/components/collapsible-answer.d.ts.map +1 -0
  25. package/dist/components/collapsible-answer.js +30 -0
  26. package/dist/components/collapsible-answer.js.map +1 -0
  27. package/dist/components/composer.d.ts +31 -0
  28. package/dist/components/composer.d.ts.map +1 -0
  29. package/dist/components/composer.js +82 -0
  30. package/dist/components/composer.js.map +1 -0
  31. package/dist/components/conversation-list.d.ts +21 -0
  32. package/dist/components/conversation-list.d.ts.map +1 -0
  33. package/dist/components/conversation-list.js +42 -0
  34. package/dist/components/conversation-list.js.map +1 -0
  35. package/dist/components/icons.d.ts +88 -0
  36. package/dist/components/icons.d.ts.map +1 -0
  37. package/dist/components/icons.js +112 -0
  38. package/dist/components/icons.js.map +1 -0
  39. package/dist/components/launcher-fab.d.ts +8 -0
  40. package/dist/components/launcher-fab.d.ts.map +1 -0
  41. package/dist/components/launcher-fab.js +6 -0
  42. package/dist/components/launcher-fab.js.map +1 -0
  43. package/dist/components/leaked-tool-call-notice.d.ts +18 -0
  44. package/dist/components/leaked-tool-call-notice.d.ts.map +1 -0
  45. package/dist/components/leaked-tool-call-notice.js +14 -0
  46. package/dist/components/leaked-tool-call-notice.js.map +1 -0
  47. package/dist/components/markdown-link.d.ts +17 -0
  48. package/dist/components/markdown-link.d.ts.map +1 -0
  49. package/dist/components/markdown-link.js +18 -0
  50. package/dist/components/markdown-link.js.map +1 -0
  51. package/dist/components/markdown-text.d.ts +26 -0
  52. package/dist/components/markdown-text.d.ts.map +1 -0
  53. package/dist/components/markdown-text.js +32 -0
  54. package/dist/components/markdown-text.js.map +1 -0
  55. package/dist/components/message-list.d.ts +24 -0
  56. package/dist/components/message-list.d.ts.map +1 -0
  57. package/dist/components/message-list.js +92 -0
  58. package/dist/components/message-list.js.map +1 -0
  59. package/dist/components/no-tools-note.d.ts +26 -0
  60. package/dist/components/no-tools-note.d.ts.map +1 -0
  61. package/dist/components/no-tools-note.js +27 -0
  62. package/dist/components/no-tools-note.js.map +1 -0
  63. package/dist/components/panel-header.d.ts +26 -0
  64. package/dist/components/panel-header.d.ts.map +1 -0
  65. package/dist/components/panel-header.js +13 -0
  66. package/dist/components/panel-header.js.map +1 -0
  67. package/dist/components/rehype-highlight-subset.d.ts +17 -0
  68. package/dist/components/rehype-highlight-subset.d.ts.map +1 -0
  69. package/dist/components/rehype-highlight-subset.js +77 -0
  70. package/dist/components/rehype-highlight-subset.js.map +1 -0
  71. package/dist/components/system-message.d.ts +15 -0
  72. package/dist/components/system-message.d.ts.map +1 -0
  73. package/dist/components/system-message.js +12 -0
  74. package/dist/components/system-message.js.map +1 -0
  75. package/dist/components/tool-chip.d.ts +19 -0
  76. package/dist/components/tool-chip.d.ts.map +1 -0
  77. package/dist/components/tool-chip.js +32 -0
  78. package/dist/components/tool-chip.js.map +1 -0
  79. package/dist/components/user-message.d.ts +9 -0
  80. package/dist/components/user-message.d.ts.map +1 -0
  81. package/dist/components/user-message.js +7 -0
  82. package/dist/components/user-message.js.map +1 -0
  83. package/dist/core/conversation-store.d.ts +107 -0
  84. package/dist/core/conversation-store.d.ts.map +1 -0
  85. package/dist/core/conversation-store.js +254 -0
  86. package/dist/core/conversation-store.js.map +1 -0
  87. package/dist/core/error-text.d.ts +19 -0
  88. package/dist/core/error-text.d.ts.map +1 -0
  89. package/dist/core/error-text.js +35 -0
  90. package/dist/core/error-text.js.map +1 -0
  91. package/dist/core/events.d.ts +98 -0
  92. package/dist/core/events.d.ts.map +1 -0
  93. package/dist/core/events.js +50 -0
  94. package/dist/core/events.js.map +1 -0
  95. package/dist/core/format.d.ts +23 -0
  96. package/dist/core/format.d.ts.map +1 -0
  97. package/dist/core/format.js +49 -0
  98. package/dist/core/format.js.map +1 -0
  99. package/dist/core/index.d.ts +27 -0
  100. package/dist/core/index.d.ts.map +1 -0
  101. package/dist/core/index.js +20 -0
  102. package/dist/core/index.js.map +1 -0
  103. package/dist/core/markdown.d.ts +26 -0
  104. package/dist/core/markdown.d.ts.map +1 -0
  105. package/dist/core/markdown.js +49 -0
  106. package/dist/core/markdown.js.map +1 -0
  107. package/dist/core/prose.d.ts +27 -0
  108. package/dist/core/prose.d.ts.map +1 -0
  109. package/dist/core/prose.js +79 -0
  110. package/dist/core/prose.js.map +1 -0
  111. package/dist/core/small-talk.d.ts +15 -0
  112. package/dist/core/small-talk.d.ts.map +1 -0
  113. package/dist/core/small-talk.js +42 -0
  114. package/dist/core/small-talk.js.map +1 -0
  115. package/dist/core/sse.d.ts +108 -0
  116. package/dist/core/sse.d.ts.map +1 -0
  117. package/dist/core/sse.js +236 -0
  118. package/dist/core/sse.js.map +1 -0
  119. package/dist/core/tool-label.d.ts +16 -0
  120. package/dist/core/tool-label.d.ts.map +1 -0
  121. package/dist/core/tool-label.js +31 -0
  122. package/dist/core/tool-label.js.map +1 -0
  123. package/dist/core/transport.d.ts +71 -0
  124. package/dist/core/transport.d.ts.map +1 -0
  125. package/dist/core/transport.js +147 -0
  126. package/dist/core/transport.js.map +1 -0
  127. package/dist/core/turn.d.ts +87 -0
  128. package/dist/core/turn.d.ts.map +1 -0
  129. package/dist/core/turn.js +232 -0
  130. package/dist/core/turn.js.map +1 -0
  131. package/dist/core/uuid.d.ts +21 -0
  132. package/dist/core/uuid.d.ts.map +1 -0
  133. package/dist/core/uuid.js +54 -0
  134. package/dist/core/uuid.js.map +1 -0
  135. package/dist/entries/chat-page.d.ts +28 -0
  136. package/dist/entries/chat-page.d.ts.map +1 -0
  137. package/dist/entries/chat-page.js +25 -0
  138. package/dist/entries/chat-page.js.map +1 -0
  139. package/dist/entries/co-pilot.d.ts +41 -0
  140. package/dist/entries/co-pilot.d.ts.map +1 -0
  141. package/dist/entries/co-pilot.js +39 -0
  142. package/dist/entries/co-pilot.js.map +1 -0
  143. package/dist/entries/lazy-panel.d.ts +15 -0
  144. package/dist/entries/lazy-panel.d.ts.map +1 -0
  145. package/dist/entries/lazy-panel.js +19 -0
  146. package/dist/entries/lazy-panel.js.map +1 -0
  147. package/dist/hooks/index.d.ts +7 -0
  148. package/dist/hooks/index.d.ts.map +1 -0
  149. package/dist/hooks/index.js +4 -0
  150. package/dist/hooks/index.js.map +1 -0
  151. package/dist/hooks/use-agent-chat.d.ts +46 -0
  152. package/dist/hooks/use-agent-chat.d.ts.map +1 -0
  153. package/dist/hooks/use-agent-chat.js +288 -0
  154. package/dist/hooks/use-agent-chat.js.map +1 -0
  155. package/dist/hooks/use-agent-config.d.ts +29 -0
  156. package/dist/hooks/use-agent-config.d.ts.map +1 -0
  157. package/dist/hooks/use-agent-config.js +45 -0
  158. package/dist/hooks/use-agent-config.js.map +1 -0
  159. package/dist/hooks/use-conversations.d.ts +19 -0
  160. package/dist/hooks/use-conversations.d.ts.map +1 -0
  161. package/dist/hooks/use-conversations.js +19 -0
  162. package/dist/hooks/use-conversations.js.map +1 -0
  163. package/dist/index.d.ts +28 -0
  164. package/dist/index.d.ts.map +1 -0
  165. package/dist/index.js +26 -0
  166. package/dist/index.js.map +1 -0
  167. package/dist/styles.css +1 -0
  168. package/package.json +105 -1
  169. package/src/branding.ts +40 -0
  170. package/src/components/approval-card.tsx +141 -0
  171. package/src/components/assistant-message.tsx +140 -0
  172. package/src/components/co-pilot-panel.tsx +109 -0
  173. package/src/components/code-block.tsx +63 -0
  174. package/src/components/collapsible-answer.tsx +55 -0
  175. package/src/components/composer.tsx +147 -0
  176. package/src/components/conversation-list.tsx +141 -0
  177. package/src/components/icons.tsx +189 -0
  178. package/src/components/launcher-fab.tsx +16 -0
  179. package/src/components/leaked-tool-call-notice.tsx +29 -0
  180. package/src/components/markdown-link.tsx +30 -0
  181. package/src/components/markdown-text.tsx +58 -0
  182. package/src/components/message-list.tsx +164 -0
  183. package/src/components/no-tools-note.tsx +33 -0
  184. package/src/components/panel-header.tsx +86 -0
  185. package/src/components/rehype-highlight-subset.ts +84 -0
  186. package/src/components/system-message.tsx +19 -0
  187. package/src/components/tool-chip.tsx +58 -0
  188. package/src/components/user-message.tsx +18 -0
  189. package/src/core/conversation-store.ts +342 -0
  190. package/src/core/error-text.ts +36 -0
  191. package/src/core/events.ts +127 -0
  192. package/src/core/format.ts +59 -0
  193. package/src/core/index.ts +115 -0
  194. package/src/core/markdown.ts +52 -0
  195. package/src/core/prose.ts +78 -0
  196. package/src/core/small-talk.ts +45 -0
  197. package/src/core/sse.ts +269 -0
  198. package/src/core/tool-label.ts +33 -0
  199. package/src/core/transport.ts +208 -0
  200. package/src/core/turn.ts +299 -0
  201. package/src/core/uuid.ts +59 -0
  202. package/src/entries/chat-page.tsx +56 -0
  203. package/src/entries/co-pilot.tsx +84 -0
  204. package/src/entries/lazy-panel.tsx +19 -0
  205. package/src/hooks/index.ts +8 -0
  206. package/src/hooks/use-agent-chat.ts +387 -0
  207. package/src/hooks/use-agent-config.ts +70 -0
  208. package/src/hooks/use-conversations.ts +39 -0
  209. package/src/index.ts +30 -0
  210. package/src/styles/_approval.scss +68 -0
  211. package/src/styles/_composer.scss +45 -0
  212. package/src/styles/_conversations.scss +160 -0
  213. package/src/styles/_messages.scss +444 -0
  214. package/src/styles/_mixins.scss +27 -0
  215. package/src/styles/_panel.scss +147 -0
  216. package/src/styles/_tokens.scss +30 -0
  217. package/src/styles.scss +16 -0
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Folds the event stream of one turn into a render model.
3
+ *
4
+ * Pure and synchronous: no fetch, no React, no timers. Every branch here is
5
+ * driven by a contract invariant, so the fixtures in `src/test-utils/fixtures`
6
+ * exercise it directly.
7
+ */
8
+
9
+ import type { ToolArgs, WireEvent } from './events'
10
+ import { EVENT } from './events'
11
+
12
+ export const TURN_STATUS = {
13
+ IDLE: 'idle',
14
+ STREAMING: 'streaming',
15
+ AWAITING_APPROVAL: 'awaiting-approval',
16
+ COMPLETE: 'complete',
17
+ ERROR: 'error',
18
+ } as const
19
+
20
+ export type TurnStatus = (typeof TURN_STATUS)[keyof typeof TURN_STATUS]
21
+
22
+ export const TOOL_STATUS = {
23
+ RUNNING: 'running',
24
+ AWAITING_APPROVAL: 'awaiting-approval',
25
+ OK: 'ok',
26
+ /** Refused — by the operator, or by the approval timing out. Not an error. */
27
+ REJECTED: 'rejected',
28
+ ERROR: 'error',
29
+ /**
30
+ * The turn ended before the tool reported back — an `error` event, a lost
31
+ * connection, or the operator stopping the turn. Whether the tool ran is
32
+ * genuinely unknown, which is why it is neither `ok` nor `error`.
33
+ */
34
+ INTERRUPTED: 'interrupted',
35
+ } as const
36
+
37
+ export type ToolStatus = (typeof TOOL_STATUS)[keyof typeof TOOL_STATUS]
38
+
39
+ /**
40
+ * How a refused write comes back.
41
+ *
42
+ * The producer answers a declined approval with an ordinary `tool_result` and
43
+ * **no `isError`** (`backend/core/agent/src/loop.js`), so without this a
44
+ * rejected write renders with the same green tick as one that ran — telling the
45
+ * operator their refusal succeeded in doing the thing they refused.
46
+ *
47
+ * It is a bare string literal over there, not an exported constant, so this is
48
+ * matched on a prefix and pinned by `turn.contract.test.ts`.
49
+ */
50
+ const REJECTED_RESULT_PREFIX = '(rejected by operator'
51
+
52
+ export function isRejectionResult(text: string): boolean {
53
+ return text.startsWith(REJECTED_RESULT_PREFIX)
54
+ }
55
+
56
+ export type ToolStep = {
57
+ id: string
58
+ name: string
59
+ args: ToolArgs
60
+ status: ToolStatus
61
+ /** The tool's answer, once it has one. */
62
+ text?: string
63
+ /** The call itself failed. */
64
+ isError?: boolean
65
+ /** The tool answered, but the answer broke its verb's contract. Implies `isError`. */
66
+ contractViolation?: string
67
+ startedAt: number
68
+ endedAt?: number
69
+ /** What renders as `· 0.4s` on the chip. */
70
+ durationMs?: number
71
+ }
72
+
73
+ export type PendingApproval = {
74
+ approvalId: string
75
+ name: string
76
+ args: ToolArgs
77
+ /** The `ToolStep` this approval gates. */
78
+ toolStepId: string
79
+ at: number
80
+ }
81
+
82
+ export type AgentTurnState = {
83
+ turnId: string | null
84
+ tools: ToolStep[]
85
+ text: string
86
+ status: TurnStatus
87
+ error: string | null
88
+ pendingApproval: PendingApproval | null
89
+ usage: Record<string, unknown> | null
90
+ }
91
+
92
+ export function createTurnState(): AgentTurnState {
93
+ return {
94
+ turnId: null,
95
+ tools: [],
96
+ text: '',
97
+ status: TURN_STATUS.IDLE,
98
+ error: null,
99
+ pendingApproval: null,
100
+ usage: null,
101
+ }
102
+ }
103
+
104
+ export function isTurnSettled(state: AgentTurnState): boolean {
105
+ return state.status === TURN_STATUS.COMPLETE || state.status === TURN_STATUS.ERROR
106
+ }
107
+
108
+ function isOpen(step: ToolStep): boolean {
109
+ return step.status === TOOL_STATUS.RUNNING || step.status === TOOL_STATUS.AWAITING_APPROVAL
110
+ }
111
+
112
+ /**
113
+ * The OLDEST step of that name still waiting on its `tool_result`.
114
+ *
115
+ * The wire carries no id linking a result to its call, so attribution is by call
116
+ * order: the producer runs tools one at a time and emits each result in the order
117
+ * the calls were made, so the first unanswered call of a name is the one this
118
+ * result belongs to. Matching the newest instead crossed durations and outputs
119
+ * over as soon as a turn called the same tool twice.
120
+ */
121
+ function findOpenStep(tools: ToolStep[], name: string): ToolStep | undefined {
122
+ return tools.find((step) => isOpen(step) && step.name === name)
123
+ }
124
+
125
+ function replaceStep(tools: ToolStep[], id: string, next: ToolStep): ToolStep[] {
126
+ return tools.map((step) => (step.id === id ? next : step))
127
+ }
128
+
129
+ /**
130
+ * Closes every step still waiting on a result.
131
+ *
132
+ * A turn can end while a call is outstanding — an `error` event, a dropped
133
+ * socket, the operator pressing stop — and a step left `running` renders as a
134
+ * spinner that never resolves, which then gets persisted into the transcript and
135
+ * read back as a turn still in flight.
136
+ */
137
+ function settleOpenSteps(tools: ToolStep[], at: number): ToolStep[] {
138
+ return tools.map((step) =>
139
+ isOpen(step) ? { ...step, status: TOOL_STATUS.INTERRUPTED, endedAt: at } : step,
140
+ )
141
+ }
142
+
143
+ /**
144
+ * Ends a turn from outside the event stream: a transport failure, an idle
145
+ * connection, or an unmount. `reduceTurn` handles the producer's own `error`
146
+ * event; this is the same closing-down for the cases that never reach it.
147
+ */
148
+ export function failTurn(state: AgentTurnState, error: string, at = Date.now()): AgentTurnState {
149
+ return {
150
+ ...state,
151
+ status: TURN_STATUS.ERROR,
152
+ error,
153
+ pendingApproval: null,
154
+ tools: settleOpenSteps(state.tools, at),
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Ends a turn the operator stopped. Not an error — they meant it — so whatever
160
+ * streamed is kept and no error is recorded, but an outstanding call is still
161
+ * closed: it did not report back and now never will.
162
+ */
163
+ export function abortTurn(state: AgentTurnState, at = Date.now()): AgentTurnState {
164
+ return {
165
+ ...state,
166
+ pendingApproval: null,
167
+ tools: settleOpenSteps(state.tools, at),
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Applies one event. Returns a new state; never mutates the one passed in.
173
+ *
174
+ * Unknown event types fall through unchanged (invariant 5) — the caller has
175
+ * usually filtered them already, but this must not throw either way.
176
+ */
177
+ export function reduceTurn(state: AgentTurnState, event: WireEvent): AgentTurnState {
178
+ const turnId = state.turnId ?? event.turnId
179
+
180
+ switch (event.type) {
181
+ case EVENT.TOKEN:
182
+ return {
183
+ ...state,
184
+ turnId,
185
+ text: state.text + event.text,
186
+ status: TURN_STATUS.STREAMING,
187
+ }
188
+
189
+ case EVENT.TOOL_CALL:
190
+ return {
191
+ ...state,
192
+ turnId,
193
+ status: TURN_STATUS.STREAMING,
194
+ tools: [
195
+ ...state.tools,
196
+ {
197
+ id: `${event.turnId}:${event.seq}`,
198
+ name: event.name,
199
+ args: event.args,
200
+ status: TOOL_STATUS.RUNNING,
201
+ startedAt: event.ts,
202
+ },
203
+ ],
204
+ }
205
+
206
+ case EVENT.PENDING_APPROVAL: {
207
+ // Only ever emitted between a `tool_call` and its `tool_result`
208
+ // (invariant 3), so the gated step is the open one with the same name.
209
+ const step = findOpenStep(state.tools, event.name)
210
+ if (!step || !event.approvalId) return { ...state, turnId }
211
+ return {
212
+ ...state,
213
+ turnId,
214
+ status: TURN_STATUS.AWAITING_APPROVAL,
215
+ tools: replaceStep(state.tools, step.id, {
216
+ ...step,
217
+ status: TOOL_STATUS.AWAITING_APPROVAL,
218
+ }),
219
+ pendingApproval: {
220
+ approvalId: event.approvalId,
221
+ name: event.name,
222
+ args: event.args,
223
+ toolStepId: step.id,
224
+ at: event.ts,
225
+ },
226
+ }
227
+ }
228
+
229
+ case EVENT.TOOL_RESULT: {
230
+ const step = findOpenStep(state.tools, event.name)
231
+ if (!step) return { ...state, turnId }
232
+
233
+ // A refusal only reaches us as this text — the decision is sent
234
+ // out-of-band, and an approval that timed out server-side was never
235
+ // decided here at all.
236
+ const wasRefused
237
+ = step.status === TOOL_STATUS.AWAITING_APPROVAL && isRejectionResult(event.text)
238
+
239
+ const settled: ToolStep = {
240
+ ...step,
241
+ status: event.isError
242
+ ? TOOL_STATUS.ERROR
243
+ : wasRefused
244
+ ? TOOL_STATUS.REJECTED
245
+ : TOOL_STATUS.OK,
246
+ text: event.text,
247
+ endedAt: event.ts,
248
+ // The operator's own deliberation is not the tool's runtime. On a gated call the
249
+ // producer says how long the prompt was up, and it comes back off the clock.
250
+ durationMs: Math.max(0, event.ts - step.startedAt - (event.approvalWaitMs ?? 0)),
251
+ ...(event.isError === undefined ? {} : { isError: event.isError }),
252
+ ...(event.contractViolation === undefined
253
+ ? {}
254
+ : { contractViolation: event.contractViolation }),
255
+ }
256
+
257
+ // Clears the approval whether the operator answered, the approval timed
258
+ // out server-side, or the turn was declined — in every case the stream has
259
+ // moved on and the prompt must stop being shown.
260
+ const stillPending
261
+ = state.pendingApproval && state.pendingApproval.toolStepId !== step.id
262
+ ? state.pendingApproval
263
+ : null
264
+
265
+ return {
266
+ ...state,
267
+ turnId,
268
+ tools: replaceStep(state.tools, step.id, settled),
269
+ pendingApproval: stillPending,
270
+ status: stillPending ? TURN_STATUS.AWAITING_APPROVAL : TURN_STATUS.STREAMING,
271
+ }
272
+ }
273
+
274
+ case EVENT.ERROR:
275
+ return {
276
+ ...state,
277
+ turnId,
278
+ status: TURN_STATUS.ERROR,
279
+ error: event.error,
280
+ pendingApproval: null,
281
+ tools: settleOpenSteps(state.tools, event.ts),
282
+ }
283
+
284
+ case EVENT.DONE:
285
+ return {
286
+ ...state,
287
+ turnId,
288
+ status: TURN_STATUS.COMPLETE,
289
+ pendingApproval: null,
290
+ // `done` may carry the whole answer. Only used when nothing streamed,
291
+ // otherwise it would duplicate the text already accumulated.
292
+ text: state.text === '' && event.text ? event.text : state.text,
293
+ usage: event.usage ?? state.usage,
294
+ }
295
+
296
+ default:
297
+ return state
298
+ }
299
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Ids for conversations and messages.
3
+ *
4
+ * `crypto.randomUUID` is defined only in a secure context, and a gateway on a
5
+ * site LAN is routinely reached over plain http — the UI is served from the
6
+ * gateway's own `staticRootPath`, so `http://10.0.0.4:3000` is an ordinary
7
+ * deployment rather than an edge case. There `randomUUID` is undefined, and
8
+ * calling it would throw on conversation creation and on every send, i.e. the
9
+ * panel would be dead on arrival. `getRandomValues` is available in insecure
10
+ * contexts, so the v4 shape is assembled from it instead.
11
+ */
12
+
13
+ /** Version and variant bits, per RFC 4122 §4.4. */
14
+ const VERSION_BYTE = 6
15
+ const VARIANT_BYTE = 8
16
+
17
+ function hex(bytes: Uint8Array): string {
18
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('')
19
+ }
20
+
21
+ function uuidFromBytes(bytes: Uint8Array): string {
22
+ const stamped = Uint8Array.from(bytes)
23
+ stamped[VERSION_BYTE] = ((stamped[VERSION_BYTE] ?? 0) & 0x0F) | 0x40
24
+ stamped[VARIANT_BYTE] = ((stamped[VARIANT_BYTE] ?? 0) & 0x3F) | 0x80
25
+
26
+ const digits = hex(stamped)
27
+ return [
28
+ digits.slice(0, 8),
29
+ digits.slice(8, 12),
30
+ digits.slice(12, 16),
31
+ digits.slice(16, 20),
32
+ digits.slice(20, 32),
33
+ ].join('-')
34
+ }
35
+
36
+ /**
37
+ * A v4 UUID where the platform offers one, and a v4-shaped id built from
38
+ * `getRandomValues` where it does not.
39
+ *
40
+ * These ids never leave the browser — they key conversations and messages in
41
+ * localStorage and React lists — so the only requirement is that two of them
42
+ * never collide within one browser.
43
+ */
44
+ export function uuid(): string {
45
+ const source = globalThis.crypto as Crypto | undefined
46
+
47
+ if (typeof source?.randomUUID === 'function') return source.randomUUID()
48
+ if (typeof source?.getRandomValues === 'function') {
49
+ return uuidFromBytes(source.getRandomValues(new Uint8Array(16)))
50
+ }
51
+
52
+ // No WebCrypto at all: a very old browser, or a stripped non-browser global.
53
+ // Uniqueness within one tab is all these ids are asked for.
54
+ const bytes = new Uint8Array(16)
55
+ for (let index = 0; index < bytes.length; index += 1) {
56
+ bytes[index] = Math.floor(Math.random() * 256)
57
+ }
58
+ return uuidFromBytes(bytes)
59
+ }
@@ -0,0 +1,56 @@
1
+ import type { JSX, ReactNode } from 'react'
2
+ import { Suspense } from 'react'
3
+
4
+ import type { ToolLabels } from '../core/tool-label'
5
+ import type { UseAgentChatOptions } from '../hooks/use-agent-chat'
6
+ import { useAgentChat } from '../hooks/use-agent-chat'
7
+ import { LazyCoPilotPanel } from './lazy-panel'
8
+
9
+ export type ChatUIEntryProps = UseAgentChatOptions & {
10
+ title?: string
11
+ status?: ReactNode
12
+ toolLabels?: ToolLabels
13
+ placeholder?: string
14
+ className?: string
15
+ }
16
+
17
+ /**
18
+ * The same conversation as {@link CoPilot}, rendered as a full-height page
19
+ * instead of a docked overlay.
20
+ *
21
+ * Zero required props and a default export, so it drops straight into the
22
+ * shell's lazy route registry:
23
+ *
24
+ * ```ts
25
+ * { path: '/chat', label: 'Co-pilot', page: () => import('@tetherto/mdk-ui-agent/chat-page') }
26
+ * ```
27
+ *
28
+ * The panel is loaded through the shared lazy boundary rather than imported
29
+ * directly — a static edge here would pull the transcript and its markdown
30
+ * renderer into the root barrel, undoing the split for `CoPilot` as well.
31
+ */
32
+ export const ChatUIEntry = ({
33
+ title,
34
+ status,
35
+ toolLabels,
36
+ placeholder,
37
+ className,
38
+ ...chatOptions
39
+ }: ChatUIEntryProps): JSX.Element => {
40
+ const chat = useAgentChat(chatOptions)
41
+
42
+ return (
43
+ <Suspense fallback={<div className="mdk-agent-panel mdk-agent-panel--page" />}>
44
+ <LazyCoPilotPanel
45
+ chat={chat}
46
+ title={title}
47
+ status={status}
48
+ toolLabels={toolLabels}
49
+ placeholder={placeholder}
50
+ className={`mdk-agent-panel--page${className ? ` ${className}` : ''}`}
51
+ />
52
+ </Suspense>
53
+ )
54
+ }
55
+
56
+ export default ChatUIEntry
@@ -0,0 +1,84 @@
1
+ import type { JSX, ReactNode } from 'react'
2
+ import { Suspense, useCallback, useState } from 'react'
3
+
4
+ import type { ToolLabels } from '../core/tool-label'
5
+ import type { UseAgentChatOptions } from '../hooks/use-agent-chat'
6
+ import { LauncherFab } from '../components/launcher-fab'
7
+ import { useAgentChat } from '../hooks/use-agent-chat'
8
+ import { LazyCoPilotPanel } from './lazy-panel'
9
+
10
+ export const COPILOT_POSITION = {
11
+ BOTTOM_RIGHT: 'bottom-right',
12
+ BOTTOM_LEFT: 'bottom-left',
13
+ } as const
14
+
15
+ export type CoPilotPosition = (typeof COPILOT_POSITION)[keyof typeof COPILOT_POSITION]
16
+
17
+ export type CoPilotProps = UseAgentChatOptions & {
18
+ title?: string
19
+ /** The line under the title, e.g. `Local model · 6 tools`. */
20
+ status?: ReactNode
21
+ /** Human labels for tool chips, keyed by the MCP tool name. */
22
+ toolLabels?: ToolLabels
23
+ placeholder?: string
24
+ defaultOpen?: boolean
25
+ position?: CoPilotPosition
26
+ className?: string
27
+ }
28
+
29
+ /**
30
+ * The Co-pilot as a docked overlay: a launcher in the corner that opens the
31
+ * panel over whatever page is showing.
32
+ *
33
+ * Mount it once, in the app root, next to the router outlet:
34
+ *
35
+ * ```tsx
36
+ * import { CoPilot } from '@tetherto/mdk-ui-agent'
37
+ *
38
+ * <div className="app-root">
39
+ * …
40
+ * <CoPilot />
41
+ * </div>
42
+ * ```
43
+ *
44
+ * Everything is optional. Inside an `MdkProvider` it picks up the gateway base
45
+ * URL and the bearer token from the provider's auth seam; outside one it falls
46
+ * back to relative URLs and no token, which is what a gateway running without an
47
+ * auth plugin serves anyway.
48
+ */
49
+ export const CoPilot = ({
50
+ title,
51
+ status,
52
+ toolLabels,
53
+ placeholder,
54
+ defaultOpen = false,
55
+ position = COPILOT_POSITION.BOTTOM_RIGHT,
56
+ className,
57
+ ...chatOptions
58
+ }: CoPilotProps): JSX.Element => {
59
+ const [open, setOpen] = useState(defaultOpen)
60
+ const chat = useAgentChat(chatOptions)
61
+
62
+ const close = useCallback(() => setOpen(false), [])
63
+ const openPanel = useCallback(() => setOpen(true), [])
64
+
65
+ return (
66
+ <div className={`mdk-agent-root mdk-agent-root--${position}${className ? ` ${className}` : ''}`}>
67
+ {open
68
+ ? (
69
+ <Suspense fallback={<div className="mdk-agent-panel mdk-agent-panel--docked" />}>
70
+ <LazyCoPilotPanel
71
+ chat={chat}
72
+ title={title}
73
+ status={status}
74
+ toolLabels={toolLabels}
75
+ placeholder={placeholder}
76
+ onClose={close}
77
+ className="mdk-agent-panel--docked"
78
+ />
79
+ </Suspense>
80
+ )
81
+ : <LauncherFab onClick={openPanel} />}
82
+ </div>
83
+ )
84
+ }
@@ -0,0 +1,19 @@
1
+ import { lazy } from 'react'
2
+
3
+ /**
4
+ * The panel, split off from whatever mounts it.
5
+ *
6
+ * Both entries load it this way, and nothing in the package's root barrel may
7
+ * import `co-pilot-panel` statically — one static edge anywhere in the graph
8
+ * collapses the split back into the caller's main chunk, which bundlers report
9
+ * as an ineffective dynamic import. That is why `CoPilotPanel` is reachable only
10
+ * from the `./panel` subpath and not from the root export.
11
+ *
12
+ * What sits behind the boundary is the transcript: the markdown renderer and the
13
+ * syntax highlighter. Eagerly bundled, they made the generated shell's main
14
+ * chunk 80% larger before anyone had opened the panel.
15
+ */
16
+ export const LazyCoPilotPanel = lazy(async () => {
17
+ const module = await import('../components/co-pilot-panel')
18
+ return { default: module.CoPilotPanel }
19
+ })
@@ -0,0 +1,8 @@
1
+ export type { UseAgentChatOptions, UseAgentChatResult } from './use-agent-chat'
2
+ export { useAgentChat } from './use-agent-chat'
3
+
4
+ export type { AgentConfigOptions } from './use-agent-config'
5
+ export { useAgentConfig } from './use-agent-config'
6
+
7
+ export type { UseConversationsResult } from './use-conversations'
8
+ export { useConversations } from './use-conversations'