@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,147 @@
1
+ import { Button } from '@tetherto/mdk-react-devkit/primitives'
2
+ import type { FormEvent, JSX, KeyboardEvent } from 'react'
3
+ import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react'
4
+
5
+ import { AGENT_LABELS } from '../branding'
6
+ import { SendIcon, StopIcon } from './icons'
7
+
8
+ /** Grows with the text up to this, then scrolls. Keeps the panel usable. */
9
+ const MAX_ROWS = 6
10
+
11
+ export type ComposerProps = {
12
+ disabled?: boolean
13
+ /**
14
+ * A turn is running. Kept separate from `disabled` — which is about the input —
15
+ * because this swaps Send for Stop, and a composer disabled for any other reason
16
+ * must not offer to stop a turn that is not there.
17
+ */
18
+ isStreaming?: boolean
19
+ placeholder?: string
20
+ /**
21
+ * Changing this focuses the input — used when the panel switches conversation,
22
+ * so starting a new one lands the caret where you are about to type. Ignored on
23
+ * first render, so mounting the panel never steals focus from the host page.
24
+ */
25
+ focusKey?: string | number | null
26
+ onSend: (text: string) => void
27
+ /** Aborts the running turn. Without it a turn going wrong can only be escaped by reloading. */
28
+ onStop?: VoidFunction
29
+ }
30
+
31
+ /**
32
+ * The input row: auto-growing, Enter sends, Shift+Enter breaks the line.
33
+ *
34
+ * `disabled` is driven by the session being busy, not by a local guard — the
35
+ * gateway allows one turn at a time per session and answers a second POST with
36
+ * a 409, and a turn paused on an approval still counts as busy.
37
+ */
38
+ const ComposerView = ({
39
+ disabled = false,
40
+ isStreaming = false,
41
+ placeholder = AGENT_LABELS.placeholder,
42
+ focusKey = null,
43
+ onSend,
44
+ onStop,
45
+ }: ComposerProps): JSX.Element => {
46
+ const [value, setValue] = useState('')
47
+ const textAreaRef = useRef<HTMLTextAreaElement>(null)
48
+ const refocusRef = useRef(false)
49
+
50
+ /**
51
+ * Hands focus back when the turn releases the input.
52
+ *
53
+ * Disabling a focused `<textarea>` makes the browser blur it, so without this
54
+ * the caret is gone after every message and the next one needs a click. Armed
55
+ * only by an actual submit, so a turn finishing never steals focus from
56
+ * somewhere else the operator has moved to.
57
+ */
58
+ useEffect(() => {
59
+ if (disabled || !refocusRef.current) return
60
+ refocusRef.current = false
61
+ textAreaRef.current?.focus()
62
+ }, [disabled])
63
+
64
+ // Focus on a conversation change, but not on the first render — mounting the
65
+ // panel (or a route page) must not pull focus out of whatever the host had.
66
+ const lastFocusKeyRef = useRef<string | number | null | undefined>(undefined)
67
+ useEffect(() => {
68
+ const previous = lastFocusKeyRef.current
69
+ lastFocusKeyRef.current = focusKey
70
+ // `disabled` is a dependency only so the focus lands after the input is
71
+ // usable again; a change to it alone must not count as a conversation
72
+ // switch, or every finished turn would grab focus.
73
+ if (previous === undefined || previous === focusKey || disabled) return
74
+ textAreaRef.current?.focus()
75
+ }, [focusKey, disabled])
76
+
77
+ // The devkit `TextArea` is a plain `<textarea>` with no auto-grow, so the
78
+ // height is measured here: reset first, otherwise scrollHeight only ever
79
+ // grows and the box never shrinks back after a deletion.
80
+ useLayoutEffect(() => {
81
+ const node = textAreaRef.current
82
+ if (!node) return
83
+ node.style.height = 'auto'
84
+ const lineHeight = Number.parseFloat(globalThis.getComputedStyle(node).lineHeight) || 20
85
+ node.style.height = `${Math.min(node.scrollHeight, lineHeight * MAX_ROWS)}px`
86
+ }, [value])
87
+
88
+ const submit = () => {
89
+ const text = value.trim()
90
+ if (!text || disabled) return
91
+ setValue('')
92
+ refocusRef.current = true
93
+ onSend(text)
94
+ }
95
+
96
+ const handleSubmit = (event: FormEvent) => {
97
+ event.preventDefault()
98
+ submit()
99
+ }
100
+
101
+ const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
102
+ // `isComposing` guards IME candidate selection, where Enter commits the
103
+ // candidate rather than the message.
104
+ if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
105
+ event.preventDefault()
106
+ submit()
107
+ }
108
+
109
+ return (
110
+ <form className="mdk-agent-composer" onSubmit={handleSubmit}>
111
+ <textarea
112
+ ref={textAreaRef}
113
+ className="mdk-agent-composer__input"
114
+ rows={1}
115
+ value={value}
116
+ placeholder={placeholder}
117
+ aria-label={AGENT_LABELS.composer}
118
+ disabled={disabled}
119
+ onChange={(event) => setValue(event.target.value)}
120
+ onKeyDown={handleKeyDown}
121
+ />
122
+ {isStreaming && onStop
123
+ ? (
124
+ <Button
125
+ type="button"
126
+ variant="primary"
127
+ className="mdk-agent-composer__send"
128
+ aria-label="Stop"
129
+ icon={<StopIcon size={18} />}
130
+ onClick={onStop}
131
+ />
132
+ )
133
+ : (
134
+ <Button
135
+ type="submit"
136
+ variant="primary"
137
+ className="mdk-agent-composer__send"
138
+ aria-label="Send"
139
+ disabled={disabled || value.trim().length === 0}
140
+ icon={<SendIcon size={18} />}
141
+ />
142
+ )}
143
+ </form>
144
+ )
145
+ }
146
+
147
+ export const Composer = memo(ComposerView)
@@ -0,0 +1,141 @@
1
+ import { Button } from '@tetherto/mdk-react-devkit/primitives'
2
+ import type { JSX } from 'react'
3
+ import { useEffect, useState } from 'react'
4
+
5
+ import type { Conversation } from '../core/conversation-store'
6
+ import { AGENT_LABELS } from '../branding'
7
+ import { formatConversationMeta } from '../core/format'
8
+ import { MessagePlusIcon, TrashIcon } from './icons'
9
+
10
+ export type ConversationListProps = {
11
+ conversations: Conversation[]
12
+ activeId: string | null
13
+ /** The conversation with a turn in flight, if any. */
14
+ runningId?: string | null
15
+ onSelect: (id: string) => void
16
+ onCreate: VoidFunction
17
+ /** Omit to hide the delete affordance entirely. */
18
+ onDelete?: (id: string) => void
19
+ }
20
+
21
+ /**
22
+ * Past conversations, newest first.
23
+ *
24
+ * "Running" is local truth, not server truth: the gateway keeps sessions in
25
+ * memory per process and exposes no way to list them, so the only turn this
26
+ * client can know about is the one it is streaming itself.
27
+ */
28
+ export const ConversationList = ({
29
+ conversations,
30
+ activeId,
31
+ runningId = null,
32
+ onSelect,
33
+ onCreate,
34
+ onDelete,
35
+ }: ConversationListProps): JSX.Element => {
36
+ // Deleting drops a transcript that exists nowhere else — the gateway keeps no
37
+ // history — so it takes two clicks rather than a modal.
38
+ const [confirmingId, setConfirmingId] = useState<string | null>(null)
39
+
40
+ // A conversation that disappears (deleted elsewhere, or storage cleared) must
41
+ // not leave the row behind it stuck in the confirming state.
42
+ useEffect(() => {
43
+ if (confirmingId && !conversations.some((c) => c.id === confirmingId)) setConfirmingId(null)
44
+ }, [conversations, confirmingId])
45
+
46
+ return (
47
+ <div className="mdk-agent-conversations">
48
+ <div className="mdk-agent-conversations__scroll">
49
+ {conversations.length === 0
50
+ ? <p className="mdk-agent-conversations__empty">No conversations yet.</p>
51
+ : null}
52
+
53
+ {conversations.map((conversation) => {
54
+ const isRunning = conversation.id === runningId
55
+ const isConfirming = conversation.id === confirmingId
56
+
57
+ return (
58
+ <div
59
+ key={conversation.id}
60
+ className={`mdk-agent-conversations__row${
61
+ conversation.id === activeId ? ' mdk-agent-conversations__row--active' : ''
62
+ }`}
63
+ >
64
+ <button
65
+ type="button"
66
+ className="mdk-agent-conversations__open"
67
+ onClick={() => onSelect(conversation.id)}
68
+ >
69
+ <span className="mdk-agent-conversations__title">{conversation.title}</span>
70
+ <span className="mdk-agent-conversations__meta">
71
+ {formatConversationMeta(conversation.updatedAt, conversation.messages.length)}
72
+ </span>
73
+ </button>
74
+
75
+ {isConfirming
76
+ ? (
77
+ <div className="mdk-agent-conversations__confirm">
78
+ <button
79
+ type="button"
80
+ className="mdk-agent-conversations__confirm-yes"
81
+ onClick={() => {
82
+ setConfirmingId(null)
83
+ onDelete?.(conversation.id)
84
+ }}
85
+ >
86
+ Delete
87
+ </button>
88
+ <button
89
+ type="button"
90
+ className="mdk-agent-conversations__confirm-no"
91
+ onClick={() => setConfirmingId(null)}
92
+ >
93
+ Cancel
94
+ </button>
95
+ </div>
96
+ )
97
+ : (
98
+ <>
99
+ <span
100
+ className={`mdk-agent-conversations__badge mdk-agent-conversations__badge--${
101
+ isRunning ? 'running' : 'idle'
102
+ }`}
103
+ >
104
+ {isRunning ? 'Running' : 'Idle'}
105
+ </span>
106
+ {onDelete
107
+ ? (
108
+ <button
109
+ type="button"
110
+ className="mdk-agent-conversations__delete"
111
+ // Deleting the conversation whose turn is streaming would
112
+ // strand the open stream with nowhere to write its result.
113
+ disabled={isRunning}
114
+ aria-label={`Delete conversation: ${conversation.title}`}
115
+ onClick={() => setConfirmingId(conversation.id)}
116
+ >
117
+ <TrashIcon size={14} />
118
+ </button>
119
+ )
120
+ : null}
121
+ </>
122
+ )}
123
+ </div>
124
+ )
125
+ })}
126
+ </div>
127
+
128
+ <div className="mdk-agent-conversations__footer">
129
+ <Button
130
+ variant="outline"
131
+ size="sm"
132
+ fullWidth
133
+ icon={<MessagePlusIcon size={16} />}
134
+ onClick={onCreate}
135
+ >
136
+ {AGENT_LABELS.newConversation}
137
+ </Button>
138
+ </div>
139
+ </div>
140
+ )
141
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * The glyphs the Co-pilot needs that the devkit icon set does not carry.
3
+ *
4
+ * Built with the devkit's `createIcon` factory so they take the same `size` /
5
+ * `color` props and inherit `currentColor` like every other icon in the system.
6
+ */
7
+
8
+ import { createIcon } from '@tetherto/mdk-react-devkit/primitives'
9
+
10
+ /**
11
+ * The launcher glyph: a round speech bubble whose tail points to the
12
+ * bottom-right corner.
13
+ *
14
+ * Geometry fitted to the design's 22×22 `Chat` instance rather than eyeballed —
15
+ * the ring fills the box (center 11, r 10.2) and the tail is a wedge between two
16
+ * points on the arc, tipped at the corner. Drawn as a single arc-plus-wedge path
17
+ * so the stroke stays continuous through the join.
18
+ *
19
+ * Rendered at 48px against the Figma export this matches ~98% of pixels; the
20
+ * residue is the hollow Figma leaves between the bubble's underside and the
21
+ * tail, which is below one pixel at this size.
22
+ */
23
+ export const ChatIcon = createIcon({
24
+ displayName: 'ChatIcon',
25
+ viewBox: '0 0 22 22',
26
+ defaultWidth: 22,
27
+ defaultHeight: 22,
28
+ path: ({ color }) => (
29
+ <path
30
+ fill="none"
31
+ stroke={color}
32
+ strokeLinecap="round"
33
+ strokeLinejoin="round"
34
+ strokeWidth="1.9"
35
+ d="M19.83 16.1A10.2 10.2 0 1 0 14.15 20.7L21 21Z"
36
+ />
37
+ ),
38
+ })
39
+
40
+ export const SendIcon = createIcon({
41
+ displayName: 'SendIcon',
42
+ viewBox: '0 0 18 18',
43
+ defaultWidth: 18,
44
+ defaultHeight: 18,
45
+ path: ({ color }) => (
46
+ <path
47
+ stroke={color}
48
+ strokeLinecap="round"
49
+ strokeLinejoin="round"
50
+ strokeWidth="1.6"
51
+ d="M16.5 1.5 8.25 9.75M16.5 1.5l-5.25 15-3-6.75-6.75-3 15-5.25Z"
52
+ />
53
+ ),
54
+ })
55
+
56
+ /**
57
+ * New conversation: a square-cornered message box with a center-bottom tail and
58
+ * a plus inside.
59
+ *
60
+ * Squared off, not rounded — the design's box runs the full 16px width with hard
61
+ * corners, and the tail drops from the middle of the bottom edge rather than
62
+ * from a corner.
63
+ */
64
+ export const MessagePlusIcon = createIcon({
65
+ displayName: 'MessagePlusIcon',
66
+ viewBox: '0 0 16 16',
67
+ defaultWidth: 16,
68
+ defaultHeight: 16,
69
+ path: ({ color }) => (
70
+ <g stroke={color} fill="none" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.1">
71
+ <path d="M0.75 0.75H15.25V12.05H9.1L7.6 15.1L6.1 12.05H0.75Z" />
72
+ <path d="M7.6 4.2V9.2M5.1 6.7H10.1" />
73
+ </g>
74
+ ),
75
+ })
76
+
77
+ /**
78
+ * Conversations: two squares, each with a rule to its right — a list whose
79
+ * bullets are boxes, not the plain stack of lines this originally drew.
80
+ */
81
+ export const ListLayoutIcon = createIcon({
82
+ displayName: 'ListLayoutIcon',
83
+ viewBox: '0 0 16 16',
84
+ defaultWidth: 16,
85
+ defaultHeight: 16,
86
+ path: ({ color }) => (
87
+ <g stroke={color} fill="none" strokeLinecap="square" strokeWidth="1.1">
88
+ <path d="M0.55 1.55H4.45V5.45H0.55Z" />
89
+ <path d="M0.55 10.55H4.45V14.45H0.55Z" />
90
+ <path d="M7.1 3.5H15.45M7.1 12.5H15.45" />
91
+ </g>
92
+ ),
93
+ })
94
+
95
+ export const CloseIcon = createIcon({
96
+ displayName: 'CloseIcon',
97
+ viewBox: '0 0 16 16',
98
+ defaultWidth: 16,
99
+ defaultHeight: 16,
100
+ path: ({ color }) => (
101
+ <path
102
+ stroke={color}
103
+ strokeLinecap="round"
104
+ strokeWidth="1.2"
105
+ d="M4.6 4.6 11.4 11.4M11.4 4.6 4.6 11.4"
106
+ />
107
+ ),
108
+ })
109
+
110
+ export const InfoIcon = createIcon({
111
+ displayName: 'InfoIcon',
112
+ viewBox: '0 0 12 12',
113
+ defaultWidth: 12,
114
+ defaultHeight: 12,
115
+ path: ({ color }) => (
116
+ <g stroke={color} fill="none" strokeLinecap="round" strokeWidth="1.1">
117
+ <circle cx="6" cy="6" r="5" />
118
+ <path d="M6 5.2v3.1M6 3.6h.005" />
119
+ </g>
120
+ ),
121
+ })
122
+
123
+ export const TrashIcon = createIcon({
124
+ displayName: 'TrashIcon',
125
+ viewBox: '0 0 16 16',
126
+ defaultWidth: 16,
127
+ defaultHeight: 16,
128
+ path: ({ color }) => (
129
+ <g stroke={color} fill="none" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.1">
130
+ <path d="M2.5 4.2h11M6.2 4.2V2.6h3.6v1.6" />
131
+ <path d="M3.9 4.2 4.6 13.4h6.8l0.7-9.2" />
132
+ <path d="M6.6 6.8v4M9.4 6.8v4" />
133
+ </g>
134
+ ),
135
+ })
136
+
137
+ export const BackIcon = createIcon({
138
+ displayName: 'BackIcon',
139
+ viewBox: '0 0 16 16',
140
+ defaultWidth: 16,
141
+ defaultHeight: 16,
142
+ path: ({ color }) => (
143
+ <path
144
+ stroke={color}
145
+ strokeLinecap="round"
146
+ strokeLinejoin="round"
147
+ strokeWidth="1.4"
148
+ d="M10 3 5 8l5 5"
149
+ />
150
+ ),
151
+ })
152
+
153
+ /** The filled square a running turn is stopped with, matching the send button's box. */
154
+ export const StopIcon = createIcon({
155
+ displayName: 'StopIcon',
156
+ viewBox: '0 0 18 18',
157
+ defaultWidth: 18,
158
+ defaultHeight: 18,
159
+ path: ({ color }) => <rect x="4" y="4" width="10" height="10" rx="1" fill={color} stroke="none" />,
160
+ })
161
+
162
+ export const TickIcon = createIcon({
163
+ displayName: 'TickIcon',
164
+ viewBox: '0 0 12 12',
165
+ defaultWidth: 12,
166
+ defaultHeight: 12,
167
+ path: ({ color }) => (
168
+ <path
169
+ stroke={color}
170
+ strokeLinecap="round"
171
+ strokeLinejoin="round"
172
+ strokeWidth="1.5"
173
+ d="m2 6.4 2.6 2.6L10 3.5"
174
+ />
175
+ ),
176
+ })
177
+
178
+ export const WarningIcon = createIcon({
179
+ displayName: 'WarningIcon',
180
+ viewBox: '0 0 12 12',
181
+ defaultWidth: 12,
182
+ defaultHeight: 12,
183
+ path: ({ color }) => (
184
+ <g stroke={color} strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.3">
185
+ <path d="M6 1.5 11 10.5H1L6 1.5Z" />
186
+ <path d="M6 5v2.2M6 9h.005" />
187
+ </g>
188
+ ),
189
+ })
@@ -0,0 +1,16 @@
1
+ import type { JSX } from 'react'
2
+
3
+ import { AGENT_LABELS } from '../branding'
4
+ import { ChatIcon } from './icons'
5
+
6
+ export type LauncherFabProps = {
7
+ label?: string
8
+ onClick: VoidFunction
9
+ }
10
+
11
+ /** The collapsed state: a 48px round launcher in the bottom-right corner. */
12
+ export const LauncherFab = ({ label = AGENT_LABELS.open, onClick }: LauncherFabProps): JSX.Element => (
13
+ <button type="button" className="mdk-agent-fab" aria-label={label} onClick={onClick}>
14
+ <ChatIcon size={22} />
15
+ </button>
16
+ )
@@ -0,0 +1,29 @@
1
+ import type { JSX } from 'react'
2
+
3
+ import type { LeakedToolCall } from '../core/prose'
4
+
5
+ export type LeakedToolCallNoticeProps = {
6
+ call: LeakedToolCall
7
+ }
8
+
9
+ /**
10
+ * Shown in place of an answer that is really a tool call the model failed to
11
+ * emit correctly.
12
+ *
13
+ * The agent's tool loop is prompt-based: the model is asked to reply with only
14
+ * `{"tool": …}`, and the producer classifies the reply as a tool call from its
15
+ * first character being `{`. A model that wraps the JSON in a ```json fence
16
+ * leads with a backtick, so the call is classified as prose and streamed to the
17
+ * operator verbatim. Rendering that raw is the most confusing failure in the
18
+ * stack; naming it is cheap and makes the retry obvious.
19
+ */
20
+ export const LeakedToolCallNotice = ({ call }: LeakedToolCallNoticeProps): JSX.Element => (
21
+ <div className="mdk-agent-leaked" role="status">
22
+ <p className="mdk-agent-leaked__title">The model returned a malformed tool call.</p>
23
+ <p className="mdk-agent-leaked__body">
24
+ {`It tried to call `}
25
+ <code className="mdk-agent-leaked__tool">{call.tool}</code>
26
+ {` but wrapped the request in formatting, so it was never run. Ask again.`}
27
+ </p>
28
+ </div>
29
+ )
@@ -0,0 +1,30 @@
1
+ import type { ComponentPropsWithoutRef, JSX } from 'react'
2
+ import type { ExtraProps } from 'react-markdown'
3
+
4
+ export type MarkdownLinkProps = ComponentPropsWithoutRef<'a'> & ExtraProps
5
+
6
+ /**
7
+ * A link inside an answer.
8
+ *
9
+ * Anything pointing away from the document opens in a new tab: the panel is docked
10
+ * over whatever page the operator is working on, and following a link in place
11
+ * would navigate the app out from under them mid-conversation. An in-document
12
+ * target — a GFM footnote reference, `#...` — has to stay in the panel to work at
13
+ * all, so it is left alone.
14
+ *
15
+ * `react-markdown` blocks `javascript:` hrefs in its default `urlTransform`, so
16
+ * this is about not losing the operator's place rather than about safety.
17
+ */
18
+ export const MarkdownLink = ({ href, children, node, ...rest }: MarkdownLinkProps): JSX.Element => {
19
+ const isInDocument = href?.startsWith('#') ?? false
20
+
21
+ return (
22
+ <a
23
+ href={href}
24
+ {...(isInDocument ? {} : { target: '_blank', rel: 'noopener noreferrer' })}
25
+ {...rest}
26
+ >
27
+ {children}
28
+ </a>
29
+ )
30
+ }
@@ -0,0 +1,58 @@
1
+ import type { JSX } from 'react'
2
+ import type { Components } from 'react-markdown'
3
+ import { memo } from 'react'
4
+ import Markdown from 'react-markdown'
5
+ import remarkGfm from 'remark-gfm'
6
+
7
+ import { unwrapWholeAnswerFence } from '../core/markdown'
8
+ import { CodeBlock } from './code-block'
9
+ import { MarkdownLink } from './markdown-link'
10
+ import { rehypeHighlightSubset } from './rehype-highlight-subset'
11
+
12
+ // Module scope, not inline: a fresh array each render makes react-markdown
13
+ // rebuild its processor for every streamed token.
14
+ const REMARK_PLUGINS = [remarkGfm]
15
+ const REHYPE_PLUGINS = [rehypeHighlightSubset]
16
+ const COMPONENTS: Components = { a: MarkdownLink, pre: CodeBlock }
17
+
18
+ export type MarkdownTextProps = {
19
+ text: string
20
+ /**
21
+ * A turn still streaming renders as plain text. A half-written fence parses as
22
+ * an unterminated code block, so markdown mid-stream flickers between layouts,
23
+ * and re-parsing the whole answer on every token is wasted work either way.
24
+ * Switch on when the turn finishes.
25
+ */
26
+ streaming?: boolean
27
+ }
28
+
29
+ /**
30
+ * Renders an assistant answer.
31
+ *
32
+ * Raw HTML is deliberately not enabled — `react-markdown` ignores it unless
33
+ * `rehype-raw` is added, which is what keeps untrusted model output from
34
+ * injecting markup. Do not add `rehype-raw` here.
35
+ *
36
+ * Memoized: a live turn re-renders the transcript, and reparsing every settled
37
+ * answer for each token of the current one is the panel's most expensive
38
+ * avoidable work.
39
+ */
40
+ const MarkdownTextView = ({ text, streaming = false }: MarkdownTextProps): JSX.Element => {
41
+ if (streaming) {
42
+ return <div className="mdk-agent-markdown mdk-agent-markdown--streaming">{text}</div>
43
+ }
44
+
45
+ return (
46
+ <div className="mdk-agent-markdown">
47
+ <Markdown
48
+ remarkPlugins={REMARK_PLUGINS}
49
+ rehypePlugins={REHYPE_PLUGINS}
50
+ components={COMPONENTS}
51
+ >
52
+ {unwrapWholeAnswerFence(text)}
53
+ </Markdown>
54
+ </div>
55
+ )
56
+ }
57
+
58
+ export const MarkdownText = memo(MarkdownTextView)