@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,387 @@
1
+ /**
2
+ * The one stateful hook: owns the session, the live turn, and persistence.
3
+ *
4
+ * Components below this are presentational — they receive already-shaped props
5
+ * and render them, per the repo's separation-of-concerns rule.
6
+ */
7
+
8
+ import type { StoreApi } from 'zustand/vanilla'
9
+ import { useCallback, useEffect, useRef, useState } from 'react'
10
+
11
+ import type { ChatMessage, Conversation, ConversationStore } from '../core/conversation-store'
12
+ import type { AgentTurnState, PendingApproval } from '../core/turn'
13
+ import type { AgentConfigOptions } from './use-agent-config'
14
+ import { conversationStore as defaultStore, MESSAGE_ROLE } from '../core/conversation-store'
15
+ import { describeAgentError, GENERIC_TURN_ERROR } from '../core/error-text'
16
+ import {
17
+ AGENT_ERROR,
18
+ AgentApiError,
19
+ createSession,
20
+ decideApproval,
21
+ deleteSession,
22
+ streamTurn,
23
+ } from '../core/transport'
24
+ import { abortTurn, createTurnState, failTurn, isTurnSettled, reduceTurn } from '../core/turn'
25
+ import { uuid } from '../core/uuid'
26
+ import { useAgentConfig } from './use-agent-config'
27
+ import { useConversations } from './use-conversations'
28
+
29
+ /**
30
+ * Said in the transcript when a turn had to open a new gateway session.
31
+ *
32
+ * Sessions live in the gateway's memory, so a restart or the agent's own TTL makes
33
+ * a stored id a 404 and the next message starts a session with no history. The
34
+ * transcript above it still reads as one conversation, so without this the operator
35
+ * asks a follow-up about "that container" and the agent has never heard of it.
36
+ */
37
+ const SESSION_RESET_NOTICE
38
+ = 'New session — the agent does not remember the messages above this line.'
39
+
40
+ function describeError(error: unknown): string {
41
+ if (error instanceof AgentApiError) return describeAgentError(error.code) ?? error.message
42
+ return GENERIC_TURN_ERROR
43
+ }
44
+
45
+ function isAbort(error: unknown): boolean {
46
+ return error instanceof DOMException && error.name === 'AbortError'
47
+ }
48
+
49
+ export type UseAgentChatOptions = AgentConfigOptions & {
50
+ /** Isolated store for tests and the catalog demo; defaults to the singleton. */
51
+ store?: StoreApi<ConversationStore>
52
+ /**
53
+ * The three silences a turn is allowed, all defaulted in `core/sse`: between
54
+ * events, while an approval is outstanding, and before the first event. Raise
55
+ * the second if your gateway's `agent.approvalTimeoutMs` is longer than five
56
+ * minutes, and the third if its model is slower to route than two minutes.
57
+ */
58
+ idleTimeoutMs?: number
59
+ approvalIdleTimeoutMs?: number
60
+ firstEventIdleTimeoutMs?: number
61
+ }
62
+
63
+ export type UseAgentChatResult = {
64
+ conversations: Conversation[]
65
+ active: Conversation | null
66
+ /** Persisted transcript of the active conversation. */
67
+ messages: ChatMessage[]
68
+ /** The turn currently streaming, rendered after `messages`. Null when idle. */
69
+ live: AgentTurnState | null
70
+ isStreaming: boolean
71
+ pendingApproval: PendingApproval | null
72
+ /** A decision has been POSTed and the stream has not resumed yet. */
73
+ isDecisionPending: boolean
74
+ canSend: boolean
75
+ send: (text: string) => void
76
+ decide: (approved: boolean) => void
77
+ abort: VoidFunction
78
+ /** Re-sends the last question, for a turn that failed. */
79
+ retry: VoidFunction
80
+ newConversation: VoidFunction
81
+ selectConversation: (id: string) => void
82
+ removeConversation: (id: string) => void
83
+ }
84
+
85
+ export function useAgentChat(options: UseAgentChatOptions = {}): UseAgentChatResult {
86
+ const {
87
+ store = defaultStore,
88
+ idleTimeoutMs,
89
+ approvalIdleTimeoutMs,
90
+ firstEventIdleTimeoutMs,
91
+ ...configOptions
92
+ } = options
93
+ const config = useAgentConfig(configOptions)
94
+ const { conversations, active, create, select, remove } = useConversations(store)
95
+
96
+ const [live, setLive] = useState<AgentTurnState | null>(null)
97
+ const [isBusy, setIsBusy] = useState(false)
98
+ const [decidedApprovalId, setDecidedApprovalId] = useState<string | null>(null)
99
+
100
+ // Read inside async callbacks, where the rendered value would be stale.
101
+ const liveRef = useRef<AgentTurnState | null>(null)
102
+ const abortRef = useRef<AbortController | null>(null)
103
+ const busyRef = useRef(false)
104
+
105
+ const frameRef = useRef<number | null>(null)
106
+
107
+ /**
108
+ * Records the live turn, and commits it to React at most once per frame.
109
+ *
110
+ * One `setState` per SSE event means the whole panel re-renders 30–60 times a
111
+ * second at token rate, for a transcript whose settled half never changed. The
112
+ * ref is updated synchronously — callbacks and the next reduce read it — and the
113
+ * scheduled commit always publishes the latest value, so no event is skipped,
114
+ * only the intermediate paints. Clearing the turn commits immediately: it is the
115
+ * end of the turn, not a frame of it.
116
+ */
117
+ const publish = useCallback((next: AgentTurnState | null) => {
118
+ liveRef.current = next
119
+
120
+ if (next === null) {
121
+ if (frameRef.current !== null) cancelAnimationFrame(frameRef.current)
122
+ frameRef.current = null
123
+ setLive(null)
124
+ return
125
+ }
126
+
127
+ if (frameRef.current !== null) return
128
+ frameRef.current = requestAnimationFrame(() => {
129
+ frameRef.current = null
130
+ setLive(liveRef.current)
131
+ })
132
+ }, [])
133
+
134
+ useEffect(
135
+ () => () => {
136
+ if (frameRef.current !== null) cancelAnimationFrame(frameRef.current)
137
+ abortRef.current?.abort()
138
+ },
139
+ [],
140
+ )
141
+
142
+ /**
143
+ * A stored `sessionId` outlives the session it names — the gateway holds
144
+ * sessions in memory per process, so a restart or the agent's own TTL makes it
145
+ * a 404. `force` mints a replacement.
146
+ */
147
+ const resolveSessionId = useCallback(
148
+ async (conversationId: string, force: boolean): Promise<string> => {
149
+ const conversation = store
150
+ .getState()
151
+ .conversations.find((candidate) => candidate.id === conversationId)
152
+ if (!force && conversation?.sessionId) return conversation.sessionId
153
+
154
+ const sessionId = await createSession(config)
155
+ store.getState().setSessionId(conversationId, sessionId)
156
+
157
+ // Only a break if there was something to lose. A conversation whose only
158
+ // message is the question now being sent has no context to carry over, so
159
+ // the first session of a conversation is not announced.
160
+ const hadContext
161
+ = conversation?.messages.some((message) => message.role === MESSAGE_ROLE.ASSISTANT) ?? false
162
+ if (hadContext) {
163
+ store.getState().appendMessage(conversationId, {
164
+ id: uuid(),
165
+ role: MESSAGE_ROLE.SYSTEM,
166
+ text: SESSION_RESET_NOTICE,
167
+ at: Date.now(),
168
+ })
169
+ }
170
+
171
+ return sessionId
172
+ },
173
+ [config, store],
174
+ )
175
+
176
+ /**
177
+ * Opens the stream, retrying once against a fresh session if the stored one is
178
+ * gone. The first `next()` is what performs the fetch — an async generator's
179
+ * body does not run until then — so the status check happens here, not later
180
+ * in the consume loop.
181
+ */
182
+ const openStream = useCallback(
183
+ async (conversationId: string, text: string, signal: AbortSignal) => {
184
+ const start = async (force: boolean) => {
185
+ const sessionId = await resolveSessionId(conversationId, force)
186
+ const iterator = streamTurn(sessionId, text, {
187
+ ...config,
188
+ ...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs }),
189
+ ...(approvalIdleTimeoutMs === undefined ? {} : { approvalIdleTimeoutMs }),
190
+ ...(firstEventIdleTimeoutMs === undefined ? {} : { firstEventIdleTimeoutMs }),
191
+ signal,
192
+ })
193
+ return { iterator, first: await iterator.next() }
194
+ }
195
+
196
+ try {
197
+ return await start(false)
198
+ } catch (error) {
199
+ if (error instanceof AgentApiError && error.is(AGENT_ERROR.SESSION_NOT_FOUND)) {
200
+ return await start(true)
201
+ }
202
+ throw error
203
+ }
204
+ },
205
+ [approvalIdleTimeoutMs, config, firstEventIdleTimeoutMs, idleTimeoutMs, resolveSessionId],
206
+ )
207
+
208
+ const runTurn = useCallback(
209
+ async (text: string) => {
210
+ busyRef.current = true
211
+ setIsBusy(true)
212
+ setDecidedApprovalId(null)
213
+
214
+ const conversationId = store.getState().activeId ?? store.getState().create()
215
+ store.getState().appendMessage(conversationId, {
216
+ id: uuid(),
217
+ role: MESSAGE_ROLE.USER,
218
+ text,
219
+ at: Date.now(),
220
+ })
221
+
222
+ const controller = new AbortController()
223
+ abortRef.current = controller
224
+
225
+ let state = createTurnState()
226
+ publish(state)
227
+
228
+ try {
229
+ const { iterator, first } = await openStream(conversationId, text, controller.signal)
230
+ let result = first
231
+ while (!result.done) {
232
+ state = reduceTurn(state, result.value)
233
+ publish(state)
234
+ result = await iterator.next()
235
+ }
236
+ } catch (error) {
237
+ // An abort is the operator's own doing: keep whatever streamed, add no
238
+ // error. Anything else becomes the turn's error. Either way a tool call
239
+ // still outstanding is closed — it will never report back now.
240
+ state = isAbort(error) ? abortTurn(state) : failTurn(state, describeError(error))
241
+ } finally {
242
+ // An abort cancels the reader rather than rejecting the read, so it
243
+ // usually ends the loop normally and never reaches the catch above. The
244
+ // closing-down a stopped turn needs therefore happens here.
245
+ const interrupted = controller.signal.aborted && !isTurnSettled(state)
246
+ if (interrupted) state = abortTurn(state)
247
+
248
+ store.getState().appendMessage(conversationId, {
249
+ id: uuid(),
250
+ role: MESSAGE_ROLE.ASSISTANT,
251
+ text: state.text,
252
+ at: Date.now(),
253
+ tools: state.tools,
254
+ error: state.error,
255
+ // Half an answer must not read back later as the agent's full statement.
256
+ ...(interrupted ? { interrupted: true } : {}),
257
+ })
258
+ publish(null)
259
+ abortRef.current = null
260
+ busyRef.current = false
261
+ setIsBusy(false)
262
+ setDecidedApprovalId(null)
263
+ }
264
+ },
265
+ [openStream, publish, store],
266
+ )
267
+
268
+ const send = useCallback(
269
+ (raw: string) => {
270
+ const text = raw.trim()
271
+ // Guarded on the ref rather than the rendered flag: two submits inside one
272
+ // frame would both see the old state and provoke a 409.
273
+ if (!text || busyRef.current) return
274
+ void runTurn(text)
275
+ },
276
+ [runTurn],
277
+ )
278
+
279
+ const decide = useCallback(
280
+ (approved: boolean) => {
281
+ const pending = liveRef.current?.pendingApproval
282
+ const conversationId = store.getState().activeId
283
+ if (!pending || !conversationId) return
284
+ const sessionId = store
285
+ .getState()
286
+ .conversations.find((conversation) => conversation.id === conversationId)?.sessionId
287
+ if (!sessionId) return
288
+
289
+ setDecidedApprovalId(pending.approvalId)
290
+ void decideApproval(sessionId, pending.approvalId, approved, config).catch((error: unknown) => {
291
+ // A 404 means the approval already settled — it timed out server-side, or
292
+ // this is a second decision. Both are indistinguishable by design, and
293
+ // the stream resumes either way, so there is nothing to report.
294
+ if (error instanceof AgentApiError && error.is(AGENT_ERROR.APPROVAL_NOT_FOUND)) return
295
+ setDecidedApprovalId(null)
296
+ })
297
+ },
298
+ [config, store],
299
+ )
300
+
301
+ const abort = useCallback(() => {
302
+ abortRef.current?.abort()
303
+ }, [])
304
+
305
+ /**
306
+ * Re-asks the last question after a failed turn.
307
+ *
308
+ * The question is re-sent as a new message rather than the failed turn being
309
+ * resumed — the gateway has no notion of resuming one — so the transcript shows
310
+ * it asked twice, which is what happened.
311
+ */
312
+ const retry = useCallback(() => {
313
+ if (busyRef.current) return
314
+ const conversationId = store.getState().activeId
315
+ if (!conversationId) return
316
+
317
+ const messages = store
318
+ .getState()
319
+ .conversations.find((conversation) => conversation.id === conversationId)?.messages ?? []
320
+ const lastQuestion = [...messages].reverse().find((message) => message.role === MESSAGE_ROLE.USER)
321
+ if (!lastQuestion) return
322
+
323
+ void runTurn(lastQuestion.text)
324
+ }, [runTurn, store])
325
+
326
+ const newConversation = useCallback(() => {
327
+ if (busyRef.current) return
328
+ create()
329
+ }, [create])
330
+
331
+ const selectConversation = useCallback(
332
+ (id: string) => {
333
+ if (busyRef.current) return
334
+ select(id)
335
+ },
336
+ [select],
337
+ )
338
+
339
+ /**
340
+ * Drops the conversation locally and releases its gateway session.
341
+ *
342
+ * The DELETE is best-effort and deliberately unawaited: the session may
343
+ * already be gone (a restart, or the agent's own TTL) and the gateway refuses
344
+ * to delete one with a turn in flight. Neither should block removing the local
345
+ * record, which is the only copy the operator can see.
346
+ */
347
+ const removeConversation = useCallback(
348
+ (id: string) => {
349
+ if (busyRef.current && store.getState().activeId === id) return
350
+
351
+ const sessionId = store
352
+ .getState()
353
+ .conversations.find((conversation) => conversation.id === id)?.sessionId
354
+
355
+ remove(id)
356
+
357
+ if (sessionId) {
358
+ void deleteSession(sessionId, config).catch(() => {
359
+ // Already collected, or still busy. The gateway reaps it either way.
360
+ })
361
+ }
362
+ },
363
+ [config, remove, store],
364
+ )
365
+
366
+ const pendingApproval = live?.pendingApproval ?? null
367
+
368
+ return {
369
+ conversations,
370
+ active,
371
+ messages: active?.messages ?? [],
372
+ live,
373
+ isStreaming: isBusy,
374
+ pendingApproval,
375
+ isDecisionPending: pendingApproval !== null && decidedApprovalId === pendingApproval.approvalId,
376
+ // A turn paused on an approval still holds the session busy, so this stays
377
+ // false through the approval too — sending now would be a 409.
378
+ canSend: !isBusy,
379
+ send,
380
+ decide,
381
+ abort,
382
+ retry,
383
+ newConversation,
384
+ selectConversation,
385
+ removeConversation,
386
+ }
387
+ }
@@ -0,0 +1,70 @@
1
+ import type { AgentTransportOptions, TokenSource } from '../core/transport'
2
+ import { useMdkAuth, useQueryClient } from '@tetherto/mdk-react-adapter'
3
+ import { getFetcher, getMdkRuntime } from '@tetherto/mdk-ui-foundation'
4
+ import { useMemo } from 'react'
5
+
6
+ export type AgentConfigOptions = {
7
+ /**
8
+ * Gateway origin. Defaults to whatever the app already configured on
9
+ * `MdkProvider`, so a host that set its API base once does not set it again
10
+ * here — and to `''` outside one, meaning relative URLs, which is what the
11
+ * generated shell wants: it proxies `/agent` in dev and is served same-origin
12
+ * in production. Set it only to override that, and remember the gateway sends
13
+ * no CORS headers if you point it at another origin.
14
+ */
15
+ apiBaseUrl?: string
16
+ /**
17
+ * Bearer token source. Defaults to the `MdkProvider`'s auth provider, and to
18
+ * no token at all outside one — which is correct for a gateway running without
19
+ * an auth plugin, where every caller is the single local operator.
20
+ */
21
+ getToken?: TokenSource
22
+ /** Injected in tests and by the catalog demo. */
23
+ fetchImpl?: typeof fetch
24
+ }
25
+
26
+ /**
27
+ * The base URL the app already configured on `MdkProvider`, or undefined.
28
+ *
29
+ * Two things make this narrower than a plain `getApiBaseUrl`. It has to survive
30
+ * having no provider at all, since the panel is meant to drop into a non-MDK
31
+ * app — `useQueryClient` throws there, and the context read happens before the
32
+ * throw, so guarding it leaves hook order intact. And `getApiBaseUrl` answers
33
+ * `http://localhost:3000` for a client MDK did not build, which would quietly
34
+ * point the panel at an origin nobody chose. A fetcher in the meta is what says
35
+ * the client came from `createMdkQueryClient`, so its base URL — including the
36
+ * empty string the shell sets deliberately — is meant rather than defaulted.
37
+ */
38
+ function useConfiguredBaseUrl(): string | undefined {
39
+ // Neither MDK package re-exports the QueryClient type, and @tanstack/react-query is not a
40
+ // dependency here — so the type comes from the hook that returns it.
41
+ let client: ReturnType<typeof useQueryClient>
42
+ try {
43
+ client = useQueryClient()
44
+ } catch {
45
+ return undefined
46
+ }
47
+ return getFetcher(client) ? getMdkRuntime(client).baseUrl : undefined
48
+ }
49
+
50
+ /**
51
+ * Resolves transport options from explicit props, falling back to the MDK
52
+ * provider.
53
+ *
54
+ * `useMdkAuth` deliberately does not throw outside an `MdkProvider`, which is
55
+ * what lets the panel be dropped into a non-MDK app with just an `apiBaseUrl`.
56
+ */
57
+ export function useAgentConfig(options: AgentConfigOptions = {}): AgentTransportOptions {
58
+ const auth = useMdkAuth()
59
+ const configuredBaseUrl = useConfiguredBaseUrl()
60
+ const { apiBaseUrl, getToken, fetchImpl } = options
61
+
62
+ return useMemo<AgentTransportOptions>(
63
+ () => ({
64
+ baseUrl: apiBaseUrl ?? configuredBaseUrl ?? '',
65
+ getToken: getToken ?? (() => auth.getToken()),
66
+ ...(fetchImpl ? { fetchImpl } : {}),
67
+ }),
68
+ [apiBaseUrl, configuredBaseUrl, getToken, fetchImpl, auth],
69
+ )
70
+ }
@@ -0,0 +1,39 @@
1
+ import type { StoreApi } from 'zustand/vanilla'
2
+ import { useMemo } from 'react'
3
+ import { useStore } from 'zustand'
4
+
5
+ import type { Conversation, ConversationStore } from '../core/conversation-store'
6
+ import { conversationStore as defaultStore } from '../core/conversation-store'
7
+
8
+ export type UseConversationsResult = {
9
+ /** Newest first — the order the Conversations view renders. */
10
+ conversations: Conversation[]
11
+ active: Conversation | null
12
+ activeId: string | null
13
+ create: ConversationStore['create']
14
+ select: ConversationStore['select']
15
+ remove: ConversationStore['remove']
16
+ }
17
+
18
+ /**
19
+ * Binds the conversation store to React.
20
+ *
21
+ * Takes a store instance so tests and the catalog demo can run against an
22
+ * isolated one; runtime callers get the module singleton.
23
+ */
24
+ export function useConversations(
25
+ store: StoreApi<ConversationStore> = defaultStore,
26
+ ): UseConversationsResult {
27
+ const conversations = useStore(store, (state) => state.conversations)
28
+ const activeId = useStore(store, (state) => state.activeId)
29
+ const create = useStore(store, (state) => state.create)
30
+ const select = useStore(store, (state) => state.select)
31
+ const remove = useStore(store, (state) => state.remove)
32
+
33
+ const active = useMemo(
34
+ () => conversations.find((conversation) => conversation.id === activeId) ?? null,
35
+ [conversations, activeId],
36
+ )
37
+
38
+ return { conversations, active, activeId, create, select, remove }
39
+ }
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @tetherto/mdk-ui-agent
3
+ *
4
+ * The operator Co-pilot, as a drop-in for any MDK UI shell. Talks to the agent
5
+ * gateway plugin (`@tetherto/mdk-app-agent`) over SSE and renders its
6
+ * six-event contract as a conversation.
7
+ *
8
+ * ```tsx
9
+ * import { CoPilot } from '@tetherto/mdk-ui-agent'
10
+ * import '@tetherto/mdk-ui-agent/styles.css'
11
+ *
12
+ * <CoPilot />
13
+ * ```
14
+ *
15
+ * `CoPilotPanel` is deliberately NOT exported here — it is reachable from
16
+ * `@tetherto/mdk-ui-agent/panel`. Both entries load it through a lazy boundary
17
+ * so the markdown renderer and syntax highlighter stay out of the host's first
18
+ * paint, and a single static import from this barrel would collapse that split
19
+ * for every consumer.
20
+ */
21
+
22
+ export { AGENT_LABELS, AGENT_NAME, OPERATOR_NAME } from './branding'
23
+ export * from './core'
24
+ export type { ChatUIEntryProps } from './entries/chat-page'
25
+
26
+ export { ChatUIEntry } from './entries/chat-page'
27
+ export type { CoPilotPosition, CoPilotProps } from './entries/co-pilot'
28
+
29
+ export { CoPilot, COPILOT_POSITION } from './entries/co-pilot'
30
+ export * from './hooks'
@@ -0,0 +1,68 @@
1
+ /* The inline approval prompt. The stream is paused behind this. */
2
+
3
+ .mdk-agent-approval {
4
+ padding: 12px 14px 14px;
5
+
6
+ /* Dashed, per the design — it reads as "pending / not yet real", which is
7
+ exactly the state a paused write is in. */
8
+ border: 1px dashed color-mix(in srgb, var(--mdk-agent-accent) 60%, transparent);
9
+ border-radius: var(--mdk-agent-radius);
10
+ background: color-mix(in srgb, var(--mdk-agent-accent) 6%, var(--mdk-agent-surface));
11
+
12
+ &__title {
13
+ margin: 0 0 12px;
14
+ color: var(--mdk-agent-accent);
15
+ font-size: 10px;
16
+ font-weight: 600;
17
+ letter-spacing: 0.08em;
18
+ text-transform: uppercase;
19
+ }
20
+
21
+ &__details {
22
+ margin: 0 0 14px;
23
+ }
24
+
25
+ &__row {
26
+ display: flex;
27
+ gap: 12px;
28
+ align-items: baseline;
29
+
30
+ & + & {
31
+ margin-top: 6px;
32
+ }
33
+ }
34
+
35
+ &__label {
36
+ flex: none;
37
+ width: 90px;
38
+ color: var(--mdk-agent-text-muted);
39
+ font-size: 11px;
40
+ text-transform: capitalize;
41
+ }
42
+
43
+ &__value {
44
+ margin: 0;
45
+ min-width: 0;
46
+ overflow-wrap: anywhere;
47
+ }
48
+
49
+ &__actions {
50
+ display: flex;
51
+ gap: 8px;
52
+ justify-content: flex-end;
53
+
54
+ /* Reject is the outline variant, which the devkit renders light-on-dark —
55
+ a white slab next to the filled Approve. The design has it dark with a
56
+ hairline border, so it reads as the secondary choice. */
57
+ button:first-child {
58
+ border: 1px solid var(--mdk-agent-border-strong);
59
+ background: transparent;
60
+ color: var(--mdk-agent-text);
61
+
62
+ &:hover:not(:disabled) {
63
+ border-color: var(--mdk-agent-danger);
64
+ color: var(--mdk-agent-danger);
65
+ }
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,45 @@
1
+ /* The input row. */
2
+
3
+ .mdk-agent-composer {
4
+ display: flex;
5
+ flex: none;
6
+ gap: 8px;
7
+ align-items: flex-end;
8
+ padding: 12px;
9
+ border-top: 1px solid var(--mdk-agent-border);
10
+
11
+ &__input {
12
+ flex: 1 1 auto;
13
+ min-width: 0;
14
+ padding: 10px 12px;
15
+ border: 1px solid var(--mdk-agent-border-strong);
16
+ border-radius: var(--mdk-agent-radius);
17
+ background: transparent;
18
+ color: var(--mdk-agent-text);
19
+ font-family: inherit;
20
+ font-size: 13px;
21
+ line-height: 19px;
22
+ resize: none;
23
+
24
+ &::placeholder {
25
+ color: var(--mdk-agent-text-muted);
26
+ }
27
+
28
+ &:focus-visible {
29
+ border-color: var(--mdk-agent-accent);
30
+ outline: none;
31
+ }
32
+
33
+ &:disabled {
34
+ cursor: not-allowed;
35
+ opacity: 0.5;
36
+ }
37
+ }
38
+
39
+ &__send {
40
+ flex: none;
41
+ width: 40px;
42
+ height: 40px;
43
+ padding: 0;
44
+ }
45
+ }