@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,342 @@
1
+ /**
2
+ * Conversation history, persisted client-side.
3
+ *
4
+ * The gateway keeps sessions in an in-memory `Map` per process — no titles, no
5
+ * timestamps, no transcripts, and nothing survives a restart — and exposes no
6
+ * route to list them. So the Conversations view is backed from here instead.
7
+ *
8
+ * A stored `sessionId` is therefore a weak reference: it 404s after a gateway
9
+ * restart or once the agent's own 30-minute session TTL lapses. That is expected,
10
+ * not an error — the transcript still reads back, and the next message mints a
11
+ * fresh session (see `useAgentChat`).
12
+ */
13
+
14
+ import { createStore } from 'zustand/vanilla'
15
+ import type { StoreApi } from 'zustand/vanilla'
16
+
17
+ import type { ToolStep } from './turn'
18
+ import { uuid } from './uuid'
19
+
20
+ export const CONVERSATION_STORAGE_KEY = 'mdk-ui-agent:conversations:v1'
21
+
22
+ /** Keeps localStorage bounded. The oldest conversations fall off the end. */
23
+ export const MAX_CONVERSATIONS = 50
24
+ /** Mirrors the agent's own per-session ceiling in `backend/core/agent/src/session-store.js`. */
25
+ export const MAX_MESSAGES_PER_CONVERSATION = 200
26
+ /**
27
+ * How much of a tool result is kept in storage.
28
+ *
29
+ * A `list_devices` result is the whole fleet serialized, and it is written again on
30
+ * every subsequent change. The full text stays in memory for the session — this is
31
+ * only what survives a reload, where the chip's tooltip is a convenience and the
32
+ * quota is not. Mirrors `HISTORY_RESULT_CHARS` in the producer's own transcript.
33
+ */
34
+ export const MAX_PERSISTED_TOOL_TEXT_CHARS = 400
35
+
36
+ export const MESSAGE_ROLE = {
37
+ USER: 'user',
38
+ ASSISTANT: 'assistant',
39
+ /** Not from the model: something the panel itself has to tell the operator. */
40
+ SYSTEM: 'system',
41
+ } as const
42
+
43
+ export type MessageRole = (typeof MESSAGE_ROLE)[keyof typeof MESSAGE_ROLE]
44
+
45
+ export type ChatMessage = {
46
+ id: string
47
+ role: MessageRole
48
+ text: string
49
+ at: number
50
+ /** Assistant only: the tool chips for this turn, in call order. */
51
+ tools?: ToolStep[]
52
+ /** Assistant only: set when the turn ended on an `error` event. Raw `ERR_*` codes included. */
53
+ error?: string | null
54
+ /**
55
+ * Assistant only: the turn was stopped before it finished — by the operator, or
56
+ * by the panel unmounting. Whatever streamed is kept, but it is not the whole
57
+ * answer and must not read back as one.
58
+ */
59
+ interrupted?: boolean
60
+ }
61
+
62
+ export type Conversation = {
63
+ id: string
64
+ title: string
65
+ /** Null until the first turn creates one, or after a stale one is discarded. */
66
+ sessionId: string | null
67
+ messages: ChatMessage[]
68
+ createdAt: number
69
+ updatedAt: number
70
+ }
71
+
72
+ export type ConversationState = {
73
+ conversations: Conversation[]
74
+ activeId: string | null
75
+ }
76
+
77
+ export type ConversationActions = {
78
+ create: (id?: string, at?: number) => string
79
+ select: (id: string) => void
80
+ remove: (id: string) => void
81
+ setSessionId: (id: string, sessionId: string | null) => void
82
+ appendMessage: (id: string, message: ChatMessage) => void
83
+ updateMessage: (id: string, messageId: string, patch: Partial<ChatMessage>) => void
84
+ reset: () => void
85
+ }
86
+
87
+ export type ConversationStore = ConversationState & ConversationActions
88
+
89
+ export type ConversationStorage = Pick<Storage, 'getItem' | 'setItem'>
90
+
91
+ export type ConversationStoreOptions = {
92
+ storageKey?: string
93
+ /** Pass `null` to disable persistence entirely (tests, SSR). */
94
+ storage?: ConversationStorage | null
95
+ }
96
+
97
+ const initialState: ConversationState = { conversations: [], activeId: null }
98
+
99
+ /**
100
+ * `localStorage` throws on access in some privacy modes and does not exist
101
+ * outside the browser, so it is probed rather than assumed.
102
+ */
103
+ function defaultStorage(): ConversationStorage | null {
104
+ try {
105
+ return globalThis.localStorage ?? null
106
+ } catch {
107
+ return null
108
+ }
109
+ }
110
+
111
+ /** First line of the first user message, which is what the design lists. */
112
+ export function deriveTitle(text: string, max = 48): string {
113
+ const line = text.trim().split('\n')[0]?.trim() ?? ''
114
+ if (line.length === 0) return 'New conversation'
115
+ return line.length <= max ? line : `${line.slice(0, max - 1).trimEnd()}…`
116
+ }
117
+
118
+ export function newConversation(id: string, at: number): Conversation {
119
+ return { id, title: 'New conversation', sessionId: null, messages: [], createdAt: at, updatedAt: at }
120
+ }
121
+
122
+ function isConversation(value: unknown): value is Conversation {
123
+ if (typeof value !== 'object' || value === null) return false
124
+ const candidate = value as Partial<Conversation>
125
+ return typeof candidate.id === 'string' && Array.isArray(candidate.messages)
126
+ }
127
+
128
+ /**
129
+ * Anything unreadable is discarded rather than repaired: the stored shape can
130
+ * predate a release, and losing local history is a far better failure than
131
+ * booting the panel into a broken state.
132
+ */
133
+ function readPersisted(storage: ConversationStorage | null, key: string): ConversationState {
134
+ if (!storage) return initialState
135
+ try {
136
+ const raw = storage.getItem(key)
137
+ if (!raw) return initialState
138
+ const parsed = JSON.parse(raw) as Partial<ConversationState>
139
+ const conversations = Array.isArray(parsed.conversations)
140
+ ? parsed.conversations.filter(isConversation)
141
+ : []
142
+ const activeId
143
+ = typeof parsed.activeId === 'string'
144
+ && conversations.some((conversation) => conversation.id === parsed.activeId)
145
+ ? parsed.activeId
146
+ : null
147
+ return { conversations, activeId }
148
+ } catch {
149
+ return initialState
150
+ }
151
+ }
152
+
153
+ /** Keeps a full fleet dump out of every write. The in-memory copy is untouched. */
154
+ function trimForStorage(conversation: Conversation): Conversation {
155
+ if (!conversation.messages.some((message) => message.tools?.length)) return conversation
156
+
157
+ return {
158
+ ...conversation,
159
+ messages: conversation.messages.map((message) =>
160
+ message.tools?.length
161
+ ? {
162
+ ...message,
163
+ tools: message.tools.map((step) =>
164
+ step.text === undefined || step.text.length <= MAX_PERSISTED_TOOL_TEXT_CHARS
165
+ ? step
166
+ : { ...step, text: `${step.text.slice(0, MAX_PERSISTED_TOOL_TEXT_CHARS)}…` },
167
+ ),
168
+ }
169
+ : message,
170
+ ),
171
+ }
172
+ }
173
+
174
+ function serialize(state: ConversationState): string {
175
+ return JSON.stringify({
176
+ conversations: state.conversations.map(trimForStorage),
177
+ activeId: state.activeId,
178
+ })
179
+ }
180
+
181
+ function writePersisted(storage: ConversationStorage | null, key: string, payload: string): void {
182
+ if (!storage) return
183
+ try {
184
+ storage.setItem(key, payload)
185
+ } catch {
186
+ // Quota exceeded or storage disabled mid-session. History is a convenience,
187
+ // never a correctness requirement, so the live conversation continues.
188
+ }
189
+ }
190
+
191
+ function touch(conversation: Conversation, at: number): Conversation {
192
+ return { ...conversation, updatedAt: at }
193
+ }
194
+
195
+ /** Newest first, capped — the order the Conversations view renders. */
196
+ function sortAndCap(conversations: Conversation[]): Conversation[] {
197
+ return [...conversations].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, MAX_CONVERSATIONS)
198
+ }
199
+
200
+ /**
201
+ * Reconciles this tab's conversations with what another tab has just written.
202
+ *
203
+ * Union by id, newer `updatedAt` wins. Without it the last tab to act overwrites
204
+ * the other's turns with its own staler snapshot — and since the gateway keeps no
205
+ * history, that is permanent loss rather than a cache miss.
206
+ *
207
+ * The trade-off is deliberate: a conversation deleted in one tab can come back
208
+ * from the other tab's copy, because "absent" and "never seen" are the same thing
209
+ * in a union. Resurrecting a transcript is the recoverable direction; losing one
210
+ * is not.
211
+ */
212
+ export function mergeConversations(mine: Conversation[], theirs: Conversation[]): Conversation[] {
213
+ const byId = new Map(mine.map((conversation) => [conversation.id, conversation]))
214
+
215
+ for (const other of theirs) {
216
+ const own = byId.get(other.id)
217
+ if (!own || other.updatedAt > own.updatedAt) byId.set(other.id, other)
218
+ }
219
+
220
+ return sortAndCap([...byId.values()])
221
+ }
222
+
223
+ /**
224
+ * Factory for an isolated conversation store. Tests and SSR should use this;
225
+ * runtime code binds to the singleton {@link conversationStore}.
226
+ */
227
+ export function createConversationStore(
228
+ options: ConversationStoreOptions = {},
229
+ ): StoreApi<ConversationStore> {
230
+ const key = options.storageKey ?? CONVERSATION_STORAGE_KEY
231
+ const storage = options.storage === undefined ? defaultStorage() : options.storage
232
+
233
+ const store = createStore<ConversationStore>((set, get) => ({
234
+ ...readPersisted(storage, key),
235
+
236
+ create: (id, at) => {
237
+ const conversationId = id ?? uuid()
238
+ const createdAt = at ?? Date.now()
239
+ set((state) => ({
240
+ conversations: sortAndCap([newConversation(conversationId, createdAt), ...state.conversations]),
241
+ activeId: conversationId,
242
+ }))
243
+ return conversationId
244
+ },
245
+
246
+ select: (id) => {
247
+ if (!get().conversations.some((conversation) => conversation.id === id)) return
248
+ set({ activeId: id })
249
+ },
250
+
251
+ remove: (id) => {
252
+ set((state) => {
253
+ const conversations = state.conversations.filter((conversation) => conversation.id !== id)
254
+ return {
255
+ conversations,
256
+ activeId: state.activeId === id ? (conversations[0]?.id ?? null) : state.activeId,
257
+ }
258
+ })
259
+ },
260
+
261
+ setSessionId: (id, sessionId) => {
262
+ set((state) => ({
263
+ conversations: state.conversations.map((conversation) =>
264
+ conversation.id === id ? { ...conversation, sessionId } : conversation,
265
+ ),
266
+ }))
267
+ },
268
+
269
+ appendMessage: (id, message) => {
270
+ set((state) => ({
271
+ conversations: sortAndCap(
272
+ state.conversations.map((conversation) => {
273
+ if (conversation.id !== id) return conversation
274
+ const messages = [...conversation.messages, message].slice(-MAX_MESSAGES_PER_CONVERSATION)
275
+ const title
276
+ = conversation.messages.length === 0 && message.role === MESSAGE_ROLE.USER
277
+ ? deriveTitle(message.text)
278
+ : conversation.title
279
+ return touch({ ...conversation, messages, title }, message.at)
280
+ }),
281
+ ),
282
+ }))
283
+ },
284
+
285
+ updateMessage: (id, messageId, patch) => {
286
+ set((state) => ({
287
+ conversations: state.conversations.map((conversation) => {
288
+ if (conversation.id !== id) return conversation
289
+ return {
290
+ ...conversation,
291
+ messages: conversation.messages.map((message) =>
292
+ message.id === messageId ? { ...message, ...patch } : message,
293
+ ),
294
+ }
295
+ }),
296
+ }))
297
+ },
298
+
299
+ reset: () => set({ ...initialState }),
300
+ }))
301
+
302
+ // The last payload written or read, so an unchanged state is not written again —
303
+ // and, with the cross-tab listener below, so two tabs cannot ping-pong identical
304
+ // writes at each other forever.
305
+ let persisted = serialize(store.getState())
306
+
307
+ store.subscribe((state) => {
308
+ const payload = serialize(state)
309
+ if (payload === persisted) return
310
+ persisted = payload
311
+ writePersisted(storage, key, payload)
312
+ })
313
+
314
+ /**
315
+ * Another tab wrote the same key: merge its conversations in rather than letting
316
+ * whichever tab acts last overwrite the other.
317
+ *
318
+ * Only wired up for real `localStorage`, because the `storage` event describes
319
+ * nothing else — an injected storage (tests, SSR) has no cross-tab notion at all.
320
+ */
321
+ if (storage && storage === globalThis.localStorage && typeof globalThis.addEventListener === 'function') {
322
+ globalThis.addEventListener('storage', (event) => {
323
+ if (event.storageArea && event.storageArea !== globalThis.localStorage) return
324
+ // A null key is `localStorage.clear()`, which drops ours too.
325
+ if (event.key !== null && event.key !== key) return
326
+
327
+ const external = readPersisted(storage, key)
328
+ const merged = mergeConversations(store.getState().conversations, external.conversations)
329
+ persisted = serialize({ conversations: merged, activeId: store.getState().activeId })
330
+ // The other tab's `activeId` is about its own window, so ours is kept.
331
+ store.setState({ conversations: merged })
332
+ })
333
+ }
334
+
335
+ return store
336
+ }
337
+
338
+ /**
339
+ * Module-level singleton holding every conversation the operator has had in this
340
+ * browser. React subscribers bind to it through `useConversations`.
341
+ */
342
+ export const conversationStore = createConversationStore()
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Operator-readable text for the failures a turn can end on.
3
+ *
4
+ * `reduceTurn` stores whatever the `error` event carried, which is a bare code
5
+ * like `ERR_AGENT_STREAM_TRUNCATED` — useful in a bug report, meaningless in a
6
+ * transcript. The raw code stays on the message; this is applied at render time,
7
+ * so the stored history keeps the diagnosable value.
8
+ */
9
+
10
+ import { ERR_STREAM_IDLE, ERR_STREAM_TRUNCATED } from './sse'
11
+ import { AGENT_ERROR } from './transport'
12
+
13
+ /** Shown when a turn dies before the gateway said anything useful. */
14
+ export const GENERIC_TURN_ERROR = 'The agent could not complete that request.'
15
+
16
+ const TEXT_FOR_CODE: Record<string, string> = {
17
+ [AGENT_ERROR.UNAVAILABLE]: 'The agent is not configured on this gateway.',
18
+ [AGENT_ERROR.TURN_ACTIVE]: 'A turn is already running in this conversation.',
19
+ [AGENT_ERROR.TEXT_REQUIRED]: 'Type a message first.',
20
+ [AGENT_ERROR.SESSION_NOT_FOUND]: 'That conversation is no longer open on the gateway.',
21
+ [ERR_STREAM_TRUNCATED]: 'The connection to the agent ended before the answer finished.',
22
+ [ERR_STREAM_IDLE]: 'The agent stopped responding, so the connection was assumed lost.',
23
+ }
24
+
25
+ /**
26
+ * Maps a code to a sentence, passing anything that is not one through unchanged.
27
+ *
28
+ * The producer's codes are all `ERR_*`, so a value in that shape that is not in
29
+ * the table is a code this release has never seen — it becomes the generic
30
+ * sentence rather than being shown verbatim. Anything else is already prose.
31
+ */
32
+ export function describeAgentError(error: string | null | undefined): string | null {
33
+ if (!error) return null
34
+ if (TEXT_FOR_CODE[error]) return TEXT_FOR_CODE[error]
35
+ return error.startsWith('ERR_') ? GENERIC_TURN_ERROR : error
36
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The agent event contract, redeclared for the browser.
3
+ *
4
+ * The producer of record is `backend/core/agent/src/events.js`. That module is
5
+ * Node-only ESM inside a private package with no `exports` map, and importing it
6
+ * would pull the model provider and the MCP client into the bundle — so the six
7
+ * events are restated here instead. `src/core/events.contract.test.ts` reads the
8
+ * backend file and fails if the two ever drift.
9
+ */
10
+
11
+ import isPlainObject from 'lodash/isPlainObject'
12
+
13
+ /** Bump only alongside the producer's `CONTRACT_VERSION`. */
14
+ export const AGENT_CONTRACT_VERSION = 'v1'
15
+
16
+ /** Reference these constants, never the raw strings, so a typo is a type error. */
17
+ export const EVENT = {
18
+ TOKEN: 'token',
19
+ TOOL_CALL: 'tool_call',
20
+ TOOL_RESULT: 'tool_result',
21
+ PENDING_APPROVAL: 'pending_approval',
22
+ ERROR: 'error',
23
+ DONE: 'done',
24
+ } as const
25
+
26
+ export type EventType = (typeof EVENT)[keyof typeof EVENT]
27
+
28
+ /** `done` and `error` only. Exactly one of them ends a turn, and it is always last. */
29
+ export const TERMINAL_EVENTS = [EVENT.DONE, EVENT.ERROR] as const
30
+
31
+ export type TerminalEventType = (typeof TERMINAL_EVENTS)[number]
32
+
33
+ export type ToolArgs = Record<string, unknown>
34
+
35
+ /** A chunk of the answer, to append to the open bubble. */
36
+ export type TokenEvent = { type: typeof EVENT.TOKEN, text: string }
37
+
38
+ /** The model chose a tool. Always followed by a `tool_result` with the same `name`. */
39
+ export type ToolCallEvent = { type: typeof EVENT.TOOL_CALL, name: string, args: ToolArgs }
40
+
41
+ /**
42
+ * The tool answered. `isError` means the call itself failed; `contractViolation`
43
+ * — which the producer only ever emits together with `isError` — means the tool
44
+ * answered but the answer broke its verb's contract.
45
+ */
46
+ export type ToolResultEvent = {
47
+ type: typeof EVENT.TOOL_RESULT
48
+ name: string
49
+ text: string
50
+ isError?: boolean
51
+ contractViolation?: string
52
+ /**
53
+ * How long the operator held the turn at the approval prompt, on a gated call only.
54
+ * Subtract it before showing a duration: the gap between a `tool_call` and its
55
+ * `tool_result` spans the wait, so a card left open for a minute reads as a minute
56
+ * of tool.
57
+ */
58
+ approvalWaitMs?: number
59
+ }
60
+
61
+ /** A write tool is waiting on the operator. The stream PAUSES here. */
62
+ export type PendingApprovalEvent = {
63
+ type: typeof EVENT.PENDING_APPROVAL
64
+ name: string
65
+ args: ToolArgs
66
+ }
67
+
68
+ /** Terminal. Distinct from a decline, which is tokens + `done` with no `tool_call`. */
69
+ export type ErrorEvent = { type: typeof EVENT.ERROR, error: string }
70
+
71
+ /** Terminal. */
72
+ export type DoneEvent = {
73
+ type: typeof EVENT.DONE
74
+ text?: string
75
+ usage?: Record<string, unknown>
76
+ }
77
+
78
+ export type AgentEvent =
79
+ | TokenEvent
80
+ | ToolCallEvent
81
+ | ToolResultEvent
82
+ | PendingApprovalEvent
83
+ | ErrorEvent
84
+ | DoneEvent
85
+
86
+ /**
87
+ * What actually arrives on the wire: the event's own fields with the gateway's
88
+ * envelope spread onto the same flat object, not nested under a key.
89
+ */
90
+ export type WireEvent = AgentEvent & {
91
+ /** Correlates every event of one turn. */
92
+ turnId: string
93
+ /** Monotonic per turn — ordering and dedup. */
94
+ seq: number
95
+ /** Stamped by the gateway on every event, despite being optional in the docs. */
96
+ ts: number
97
+ /** Present ONLY on `pending_approval`. Echo it back to decide. */
98
+ approvalId?: string
99
+ }
100
+
101
+ const EVENT_TYPES = new Set<string>(Object.values(EVENT))
102
+ const TERMINAL_TYPES = new Set<string>(TERMINAL_EVENTS)
103
+
104
+ export function isTerminal(event: Pick<AgentEvent, 'type'>): boolean {
105
+ return TERMINAL_TYPES.has(event.type)
106
+ }
107
+
108
+ /**
109
+ * Structural check only — enough to decide whether we know how to render it.
110
+ *
111
+ * Contract invariant 5: unknown event types MUST be ignored, not thrown on. That
112
+ * is what lets the producer add event types without a breaking release, so this
113
+ * returns false rather than raising.
114
+ */
115
+ export function isAgentEvent(value: unknown): value is AgentEvent {
116
+ // Every event arrives from `JSON.parse`, so a plain object is exactly the shape
117
+ // to accept — anything else on the wire is malformed by definition.
118
+ if (!isPlainObject(value)) return false
119
+ const type = (value as { type?: unknown }).type
120
+ return typeof type === 'string' && EVENT_TYPES.has(type)
121
+ }
122
+
123
+ export function isWireEvent(value: unknown): value is WireEvent {
124
+ if (!isAgentEvent(value)) return false
125
+ const { turnId, seq } = value as Partial<WireEvent>
126
+ return typeof turnId === 'string' && typeof seq === 'number'
127
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Formatting for the conversation list.
3
+ *
4
+ * Deliberately local-time, not the fleet timezone from `timezoneStore`: these
5
+ * are records of when the operator had a conversation in this browser, not
6
+ * telemetry timestamps.
7
+ */
8
+
9
+ const DAY_MS = 86_400_000
10
+
11
+ function startOfDay(at: number): number {
12
+ const date = new Date(at)
13
+ date.setHours(0, 0, 0, 0)
14
+ return date.getTime()
15
+ }
16
+
17
+ /** `Today` / `Yesterday` / `Jul 28`. */
18
+ export function formatDay(at: number, now: number = Date.now()): string {
19
+ const days = Math.round((startOfDay(now) - startOfDay(at)) / DAY_MS)
20
+ if (days <= 0) return 'Today'
21
+ if (days === 1) return 'Yesterday'
22
+ return new Date(at).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
23
+ }
24
+
25
+ /** `14:32`, 24-hour so it lines up in a monospace column. */
26
+ export function formatClock(at: number): string {
27
+ return new Date(at).toLocaleTimeString(undefined, {
28
+ hour: '2-digit',
29
+ minute: '2-digit',
30
+ hour12: false,
31
+ })
32
+ }
33
+
34
+ export function formatMessageCount(count: number): string {
35
+ return count === 1 ? '1 message' : `${count} messages`
36
+ }
37
+
38
+ /**
39
+ * `4 conversations · 1 running` — the header line for the conversations view.
40
+ *
41
+ * "Running" counts only what this client is streaming: the gateway holds
42
+ * sessions in memory per process and exposes no way to list them, so a turn
43
+ * started in another tab is invisible here.
44
+ */
45
+ export function formatConversationSummary(count: number, running: boolean): string {
46
+ const conversations = count === 1 ? '1 conversation' : `${count} conversations`
47
+ return running ? `${conversations} · 1 running` : conversations
48
+ }
49
+
50
+ /** `Today · 14:32 · 6 messages` */
51
+ export function formatConversationMeta(
52
+ updatedAt: number,
53
+ messageCount: number,
54
+ now?: number,
55
+ ): string {
56
+ return [formatDay(updatedAt, now), formatClock(updatedAt), formatMessageCount(messageCount)].join(
57
+ ' · ',
58
+ )
59
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The headless half of the package: contract types, SSE transport, the turn
3
+ * reducer and the conversation store. No React, no DOM.
4
+ *
5
+ * Exported as a subpath (`@tetherto/mdk-ui-agent/core`) so a consumer building
6
+ * its own chat surface can reuse the protocol work without the components.
7
+ */
8
+
9
+ export type {
10
+ ChatMessage,
11
+ Conversation,
12
+ ConversationActions,
13
+ ConversationState,
14
+ ConversationStorage,
15
+ ConversationStore,
16
+ ConversationStoreOptions,
17
+ MessageRole,
18
+ } from './conversation-store'
19
+ export {
20
+ CONVERSATION_STORAGE_KEY,
21
+ conversationStore,
22
+ createConversationStore,
23
+ deriveTitle,
24
+ MAX_CONVERSATIONS,
25
+ MAX_MESSAGES_PER_CONVERSATION,
26
+ MAX_PERSISTED_TOOL_TEXT_CHARS,
27
+ mergeConversations,
28
+ MESSAGE_ROLE,
29
+ newConversation,
30
+ } from './conversation-store'
31
+
32
+ export { describeAgentError, GENERIC_TURN_ERROR } from './error-text'
33
+
34
+ export type {
35
+ AgentEvent,
36
+ DoneEvent,
37
+ ErrorEvent,
38
+ EventType,
39
+ PendingApprovalEvent,
40
+ TerminalEventType,
41
+ TokenEvent,
42
+ ToolArgs,
43
+ ToolCallEvent,
44
+ ToolResultEvent,
45
+ WireEvent,
46
+ } from './events'
47
+ export {
48
+ AGENT_CONTRACT_VERSION,
49
+ EVENT,
50
+ isAgentEvent,
51
+ isTerminal,
52
+ isWireEvent,
53
+ TERMINAL_EVENTS,
54
+ } from './events'
55
+
56
+ export {
57
+ formatClock,
58
+ formatConversationMeta,
59
+ formatConversationSummary,
60
+ formatDay,
61
+ formatMessageCount,
62
+ } from './format'
63
+
64
+ export { clipToBoundary, unwrapWholeAnswerFence } from './markdown'
65
+
66
+ export type { LeakedToolCall } from './prose'
67
+
68
+ export { parseLeakedToolCall } from './prose'
69
+ export { isSmallTalk } from './small-talk'
70
+
71
+ export type { ReadAgentStreamOptions, ReadFramesOptions } from './sse'
72
+ export {
73
+ APPROVAL_IDLE_TIMEOUT_MS,
74
+ ERR_STREAM_IDLE,
75
+ ERR_STREAM_TRUNCATED,
76
+ FIRST_EVENT_IDLE_TIMEOUT_MS,
77
+ parseFrame,
78
+ readAgentStream,
79
+ readFrames,
80
+ STREAM_IDLE_TIMEOUT_MS,
81
+ StreamIdleError,
82
+ } from './sse'
83
+
84
+ export type { ToolLabels } from './tool-label'
85
+ export { formatDuration, humanizeToolName, toolLabel } from './tool-label'
86
+
87
+ export type {
88
+ AgentErrorCode,
89
+ AgentTransportOptions,
90
+ StreamTurnOptions,
91
+ TokenSource,
92
+ } from './transport'
93
+ export {
94
+ AGENT_ERROR,
95
+ AGENT_ROUTE_PREFIX,
96
+ AgentApiError,
97
+ createSession,
98
+ decideApproval,
99
+ deleteSession,
100
+ streamTurn,
101
+ } from './transport'
102
+
103
+ export type { AgentTurnState, PendingApproval, ToolStatus, ToolStep, TurnStatus } from './turn'
104
+ export {
105
+ abortTurn,
106
+ createTurnState,
107
+ failTurn,
108
+ isRejectionResult,
109
+ isTurnSettled,
110
+ reduceTurn,
111
+ TOOL_STATUS,
112
+ TURN_STATUS,
113
+ } from './turn'
114
+
115
+ export { uuid } from './uuid'