@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
package/package.json CHANGED
@@ -1 +1,105 @@
1
- {"name":"@tetherto/mdk-ui-agent","version":"0.0.0"}
1
+ {
2
+ "name": "@tetherto/mdk-ui-agent",
3
+ "type": "module",
4
+ "version": "0.12.1",
5
+ "private": false,
6
+ "description": "Injectable operator Co-pilot chat UI for the MDK agent gateway plugin. Streams the six-event agent contract over SSE and renders it as a docked panel or a full-page route.",
7
+ "license": "Apache-2.0",
8
+ "sideEffects": [
9
+ "*.css",
10
+ "*.scss"
11
+ ],
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js",
16
+ "default": "./dist/index.js"
17
+ },
18
+ "./core": {
19
+ "types": "./dist/core/index.d.ts",
20
+ "import": "./dist/core/index.js",
21
+ "default": "./dist/core/index.js"
22
+ },
23
+ "./chat-page": {
24
+ "types": "./dist/entries/chat-page.d.ts",
25
+ "import": "./dist/entries/chat-page.js",
26
+ "default": "./dist/entries/chat-page.js"
27
+ },
28
+ "./panel": {
29
+ "types": "./dist/components/co-pilot-panel.d.ts",
30
+ "import": "./dist/components/co-pilot-panel.js",
31
+ "default": "./dist/components/co-pilot-panel.js"
32
+ },
33
+ "./styles.css": "./dist/styles.css",
34
+ "./package.json": "./package.json"
35
+ },
36
+ "main": "./dist/index.js",
37
+ "types": "./dist/index.d.ts",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/tetherto/mdk.git",
41
+ "directory": "ui/packages/ui-agent"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "files": [
47
+ "dist",
48
+ "src",
49
+ "!src/**/*.test.*",
50
+ "!src/**/specs",
51
+ "!src/**/test-utils"
52
+ ],
53
+ "scripts": {
54
+ "build": "npm run check:styles && npm run build:ts && npm run build:scss",
55
+ "prepublishOnly": "npm run build",
56
+ "build:ts": "rimraf dist tsconfig.tsbuildinfo tsconfig.build.tsbuildinfo && tsc -p tsconfig.build.json",
57
+ "build:scss": "vite build",
58
+ "check:styles": "node scripts/check-style-forwards.mjs",
59
+ "clean": "rimraf dist node_modules .turbo",
60
+ "dev": "concurrently -n ts,scss -c cyan,magenta \"npm run dev:ts\" \"npm run dev:scss\"",
61
+ "dev:ts": "tsc --watch -p tsconfig.build.json",
62
+ "dev:scss": "vite build --watch",
63
+ "lint": "oxlint",
64
+ "lint:fix": "oxlint --fix",
65
+ "format": "oxlint --fix",
66
+ "test": "vitest run --config vitest.config.js",
67
+ "test:watch": "vitest --watch",
68
+ "test:coverage": "vitest run --config vitest.config.js --coverage",
69
+ "typecheck": "tsc --noEmit",
70
+ "watch": "npm run dev",
71
+ "watch:ts": "npm run dev:ts",
72
+ "watch:scss": "npm run dev:scss"
73
+ },
74
+ "peerDependencies": {
75
+ "react": "^19.0.0",
76
+ "react-dom": "^19.0.0"
77
+ },
78
+ "dependencies": {
79
+ "@tetherto/mdk-react-adapter": "^0.12.1",
80
+ "@tetherto/mdk-react-devkit": "^0.12.1",
81
+ "@tetherto/mdk-ui-foundation": "^0.12.1",
82
+ "highlight.js": "^11.11.1",
83
+ "lodash": "^4.18.1",
84
+ "lowlight": "^3.3.0",
85
+ "react-markdown": "^10.1.0",
86
+ "remark-gfm": "^4.0.1",
87
+ "zustand": "^5.0.0"
88
+ },
89
+ "devDependencies": {
90
+ "@tanstack/react-query": "^5.59.0",
91
+ "@testing-library/jest-dom": "^6.9.1",
92
+ "@testing-library/react": "^16.1.0",
93
+ "@testing-library/user-event": "^14.5.2",
94
+ "@types/lodash": "^4.17.24",
95
+ "@types/react": "^19.2.14",
96
+ "@types/react-dom": "^19.2.3",
97
+ "@vitejs/plugin-react": "^6.0.2",
98
+ "@vitest/coverage-v8": "^4.1.2",
99
+ "happy-dom": "^21.0.4",
100
+ "react": "^19.2.0",
101
+ "react-dom": "^19.2.0",
102
+ "sass": "^1.83.0",
103
+ "vitest": "^4.1.2"
104
+ }
105
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The product name, in one place.
3
+ *
4
+ * "Co-pilot" is a placeholder — it collides with GitHub Copilot and the final
5
+ * name has not been decided. Every user-visible string in the package derives
6
+ * from `AGENT_NAME` below, so renaming it is a one-line change here.
7
+ *
8
+ * Deliberately kept out of the identifiers: the package is `@tetherto/mdk-ui-agent`,
9
+ * the CSS classes are `mdk-agent-*`, and the storage key is `mdk-ui-agent:*`.
10
+ * None of those need to move when the name does. The exported component names
11
+ * (`CoPilot`, `CoPilotProps`, …) are the only remaining mention, and they are a
12
+ * mechanical rename plus a deprecated alias whenever the decision lands.
13
+ *
14
+ * Hosts that want a different name per deployment do not need to fork this:
15
+ * pass `title` to `CoPilot` / `ChatUIEntry`.
16
+ */
17
+
18
+ export const AGENT_NAME = 'Co-pilot'
19
+
20
+ /** How the operator's own turns are labelled. */
21
+ export const OPERATOR_NAME = 'Operator'
22
+
23
+ const lower = AGENT_NAME.toLowerCase()
24
+
25
+ /**
26
+ * Accessible names and headings, derived so they stay consistent with
27
+ * {@link AGENT_NAME}.
28
+ */
29
+ export const AGENT_LABELS = {
30
+ name: AGENT_NAME,
31
+ operator: OPERATOR_NAME,
32
+ open: `Open ${lower}`,
33
+ close: `Close ${lower}`,
34
+ composer: `Message the ${lower}`,
35
+ conversations: 'Conversations',
36
+ newConversation: 'New conversation',
37
+ backToConversation: 'Back to conversation',
38
+ approvalRequired: 'Approval required',
39
+ placeholder: 'Ask about your fleet...',
40
+ } as const
@@ -0,0 +1,141 @@
1
+ import { Button } from '@tetherto/mdk-react-devkit/primitives'
2
+ import isNil from 'lodash/isNil'
3
+ import type { JSX } from 'react'
4
+
5
+ import type { ToolLabels } from '../core/tool-label'
6
+ import type { PendingApproval } from '../core/turn'
7
+ import { AGENT_LABELS } from '../branding'
8
+ import { humanizeToolName, toolLabel } from '../core/tool-label'
9
+
10
+ /** Args the reference site tools use to name their target, most specific first. */
11
+ const TARGET_KEYS = ['ref', 'deviceId', 'device', 'id', 'name'] as const
12
+
13
+ /**
14
+ * Friendlier names for argument keys the reference tools use.
15
+ *
16
+ * `action` matters most: without it the card shows two rows both labelled
17
+ * "Action" — the tool itself, and the tool's `action` argument — which is what
18
+ * the design calls "Command". Anything not listed is humanized from its key.
19
+ */
20
+ const ARG_LABELS: Record<string, string> = {
21
+ action: 'Command',
22
+ mode: 'Mode',
23
+ family: 'Family',
24
+ state: 'State',
25
+ }
26
+
27
+ /** Shown for an argument the tool declared but sent no value for. */
28
+ const EMPTY_VALUE = '—'
29
+
30
+ const describeValue = (value: unknown): string => {
31
+ if (isNil(value)) return EMPTY_VALUE
32
+ if (typeof value === 'string') return value
33
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
34
+ return JSON.stringify(value)
35
+ }
36
+
37
+ export type ApprovalRow = { label: string, value: string }
38
+
39
+ /**
40
+ * Flattens the tool args into the Action / Device / Command rows the design
41
+ * shows.
42
+ *
43
+ * Tools are supplied by whichever MCP server the operator points the agent at,
44
+ * so no argument name is guaranteed. The target is looked up across the names
45
+ * the reference tools use and everything else is listed as-is, which degrades to
46
+ * a plain key/value dump for an unknown tool rather than to nothing.
47
+ */
48
+ export function describeApproval(approval: PendingApproval, labels?: ToolLabels): ApprovalRow[] {
49
+ const rows: ApprovalRow[] = [{ label: 'Action', value: toolLabel(approval.name, labels) }]
50
+
51
+ const targetKey = TARGET_KEYS.find((key) => key in approval.args)
52
+ if (targetKey) rows.push({ label: 'Device', value: describeValue(approval.args[targetKey]) })
53
+
54
+ for (const [key, value] of Object.entries(approval.args)) {
55
+ if (key === targetKey) continue
56
+ rows.push({ label: ARG_LABELS[key] ?? humanizeToolName(key), value: describeValue(value) })
57
+ }
58
+
59
+ return rows
60
+ }
61
+
62
+ /**
63
+ * The one-line "here is what you are about to allow", shown above the card.
64
+ *
65
+ * Synthesized rather than streamed: `pending_approval` arrives between a
66
+ * `tool_call` and its `tool_result`, and the model does not emit a single token
67
+ * until after the tool returns — so at the moment the operator is asked to
68
+ * decide, the agent has said nothing at all. The design shows a sentence there,
69
+ * and it has to be built from the call itself.
70
+ */
71
+ export function describeApprovalIntent(approval: PendingApproval, labels?: ToolLabels): string {
72
+ const action = toolLabel(approval.name, labels).toLowerCase()
73
+ const targetKey = TARGET_KEYS.find((key) => key in approval.args)
74
+ const target = targetKey ? describeValue(approval.args[targetKey]) : null
75
+
76
+ return target
77
+ ? `This runs ${action} on ${target}. Review and approve to proceed.`
78
+ : `This runs ${action}. Review and approve to proceed.`
79
+ }
80
+
81
+ export type ApprovalCardProps = {
82
+ approval: PendingApproval
83
+ toolLabels?: ToolLabels
84
+ /** A decision has been sent; the stream has not resumed yet. */
85
+ pending?: boolean
86
+ onDecide: (approved: boolean) => void
87
+ }
88
+
89
+ /**
90
+ * The inline approval prompt. The stream is paused behind this.
91
+ *
92
+ * Rendered in the transcript rather than as a blocking modal, because it is not
93
+ * always the operator who resolves it: an approval left unanswered for
94
+ * `agent.approvalTimeoutMs` is auto-rejected server-side and the stream resumes
95
+ * on its own. The card must be able to disappear because the turn moved on — a
96
+ * modal that only closes on click would strand the operator on a dead prompt.
97
+ * Unmounting is driven by the reducer clearing `pendingApproval`.
98
+ */
99
+ export const ApprovalCard = ({
100
+ approval,
101
+ toolLabels,
102
+ pending = false,
103
+ onDecide,
104
+ }: ApprovalCardProps): JSX.Element => {
105
+ const rows = describeApproval(approval, toolLabels)
106
+
107
+ return (
108
+ <section className="mdk-agent-approval" aria-label={AGENT_LABELS.approvalRequired}>
109
+ <h3 className="mdk-agent-approval__title">{AGENT_LABELS.approvalRequired}</h3>
110
+
111
+ <dl className="mdk-agent-approval__details">
112
+ {rows.map((row) => (
113
+ <div className="mdk-agent-approval__row" key={row.label}>
114
+ <dt className="mdk-agent-approval__label">{row.label}</dt>
115
+ <dd className="mdk-agent-approval__value">{row.value}</dd>
116
+ </div>
117
+ ))}
118
+ </dl>
119
+
120
+ <div className="mdk-agent-approval__actions">
121
+ <Button
122
+ variant="outline"
123
+ size="sm"
124
+ disabled={pending}
125
+ onClick={() => onDecide(false)}
126
+ >
127
+ Reject
128
+ </Button>
129
+ <Button
130
+ variant="primary"
131
+ size="sm"
132
+ loading={pending}
133
+ disabled={pending}
134
+ onClick={() => onDecide(true)}
135
+ >
136
+ Approve
137
+ </Button>
138
+ </div>
139
+ </section>
140
+ )
141
+ }
@@ -0,0 +1,140 @@
1
+ import { Loader } from '@tetherto/mdk-react-devkit/primitives'
2
+ import type { JSX } from 'react'
3
+ import { memo, useMemo } from 'react'
4
+
5
+ import type { ToolLabels } from '../core/tool-label'
6
+ import type { PendingApproval, ToolStep } from '../core/turn'
7
+ import { AGENT_LABELS } from '../branding'
8
+ import { describeAgentError } from '../core/error-text'
9
+ import { parseLeakedToolCall } from '../core/prose'
10
+ import { isSmallTalk } from '../core/small-talk'
11
+ import { ApprovalCard, describeApprovalIntent } from './approval-card'
12
+ import { CollapsibleAnswer } from './collapsible-answer'
13
+ import { LeakedToolCallNotice } from './leaked-tool-call-notice'
14
+ import { NoToolsNote } from './no-tools-note'
15
+ import { ToolChip } from './tool-chip'
16
+
17
+ export type AssistantMessageProps = {
18
+ text: string
19
+ /** The operator message this answers — only read to decide whether it was a fleet question. */
20
+ question?: string
21
+ tools?: ToolStep[]
22
+ /** The `error` the turn ended on. A raw `ERR_*` code is mapped before it is shown. */
23
+ error?: string | null
24
+ /** The turn was stopped before it finished, so this is a part of an answer. */
25
+ interrupted?: boolean
26
+ /** The turn is still running: render plain text and the thinking indicator. */
27
+ streaming?: boolean
28
+ toolLabels?: ToolLabels
29
+ approval?: PendingApproval | null
30
+ isDecisionPending?: boolean
31
+ onDecide?: (approved: boolean) => void
32
+ /** Offered on a failed turn, so the operator does not retype the question. */
33
+ onRetry?: VoidFunction
34
+ }
35
+
36
+ const AssistantMessageView = ({
37
+ text,
38
+ question = '',
39
+ tools = [],
40
+ error = null,
41
+ interrupted = false,
42
+ streaming = false,
43
+ toolLabels,
44
+ approval = null,
45
+ isDecisionPending = false,
46
+ onDecide,
47
+ onRetry,
48
+ }: AssistantMessageProps): JSX.Element => {
49
+ // Only worth checking once the turn has settled — a partial answer can look
50
+ // like a bare JSON object simply because the rest has not arrived yet.
51
+ const leaked = useMemo(() => (streaming ? null : parseLeakedToolCall(text)), [streaming, text])
52
+
53
+ const hasText = text.trim().length > 0
54
+ const isThinking = streaming && !hasText && approval === null
55
+
56
+ // The chip for the gated step is suppressed while its card is up: the card
57
+ // already names the action, the device and the command, so the chip would just
58
+ // restate it directly above.
59
+ const chips = approval ? tools.filter((step) => step.id !== approval.toolStepId) : tools
60
+
61
+ // Stated only once the turn has settled — mid-stream the tools may simply not
62
+ // have been called *yet*. A leaked call and an errored turn already say their
63
+ // own thing, so the note would only add noise there. Nor is it said against a
64
+ // greeting: consulting nothing is the right answer to "hey", and the marker
65
+ // was appearing on every one of them.
66
+ const answeredWithoutTools
67
+ = !streaming
68
+ && tools.length === 0
69
+ && hasText
70
+ && leaked === null
71
+ && !error
72
+ && !isSmallTalk(question)
73
+
74
+ return (
75
+ <div className="mdk-agent-message mdk-agent-message--assistant">
76
+ <p className="mdk-agent-message__role">
77
+ <span className="mdk-agent-message__dot" aria-hidden="true" />
78
+ {AGENT_LABELS.name}
79
+ {answeredWithoutTools ? <NoToolsNote /> : null}
80
+ </p>
81
+
82
+ {chips.length > 0
83
+ ? (
84
+ <div className="mdk-agent-message__tools">
85
+ {chips.map((step) => (
86
+ <ToolChip key={step.id} step={step} toolLabels={toolLabels} />
87
+ ))}
88
+ </div>
89
+ )
90
+ : null}
91
+
92
+ {approval
93
+ ? (
94
+ <p className="mdk-agent-message__intent">
95
+ {describeApprovalIntent(approval, toolLabels)}
96
+ </p>
97
+ )
98
+ : null}
99
+
100
+ {/* `inline`, or the loader's 200px block height strands the dots ~100px below this
101
+ label with dead space between, and the turn reads as frozen while it is working. */}
102
+ {isThinking ? <Loader inline size={6} count={3} color="orange" /> : null}
103
+
104
+ {hasText && leaked === null ? <CollapsibleAnswer text={text} streaming={streaming} /> : null}
105
+ {hasText && leaked !== null ? <LeakedToolCallNotice call={leaked} /> : null}
106
+
107
+ {approval && onDecide
108
+ ? (
109
+ <ApprovalCard
110
+ approval={approval}
111
+ toolLabels={toolLabels}
112
+ pending={isDecisionPending}
113
+ onDecide={onDecide}
114
+ />
115
+ )
116
+ : null}
117
+
118
+ {interrupted && !error
119
+ ? <p className="mdk-agent-message__note">Stopped early — this is not the whole answer.</p>
120
+ : null}
121
+
122
+ {error
123
+ ? (
124
+ <p className="mdk-agent-message__error" role="alert">
125
+ {describeAgentError(error)}
126
+ {onRetry
127
+ ? (
128
+ <button type="button" className="mdk-agent-message__retry" onClick={onRetry}>
129
+ Try again
130
+ </button>
131
+ )
132
+ : null}
133
+ </p>
134
+ )
135
+ : null}
136
+ </div>
137
+ )
138
+ }
139
+
140
+ export const AssistantMessage = memo(AssistantMessageView)
@@ -0,0 +1,109 @@
1
+ import type { JSX, ReactNode } from 'react'
2
+ import { useCallback, useState } from 'react'
3
+
4
+ import type { ToolLabels } from '../core/tool-label'
5
+ import type { UseAgentChatResult } from '../hooks/use-agent-chat'
6
+ import type { PanelView } from './panel-header'
7
+ import { AGENT_LABELS } from '../branding'
8
+ import { formatConversationSummary } from '../core/format'
9
+ import { Composer } from './composer'
10
+ import { ConversationList } from './conversation-list'
11
+ import { MessageList } from './message-list'
12
+ import { PANEL_VIEW, PanelHeader } from './panel-header'
13
+
14
+ export type CoPilotPanelProps = {
15
+ /** The single hook instance. Both entries own one and pass it down. */
16
+ chat: UseAgentChatResult
17
+ title?: string
18
+ status?: ReactNode
19
+ toolLabels?: ToolLabels
20
+ placeholder?: string
21
+ /** Omitted by the route page, which has nothing to collapse into. */
22
+ onClose?: VoidFunction | undefined
23
+ className?: string
24
+ }
25
+
26
+ /** The panel body, shared by the docked overlay and the full-page route. */
27
+ export const CoPilotPanel = ({
28
+ chat,
29
+ title = AGENT_LABELS.name,
30
+ status,
31
+ toolLabels,
32
+ placeholder,
33
+ onClose,
34
+ className,
35
+ }: CoPilotPanelProps): JSX.Element => {
36
+ const [view, setView] = useState<PanelView>(PANEL_VIEW.CHAT)
37
+
38
+ const toggleConversations = useCallback(() => {
39
+ setView((current) => (current === PANEL_VIEW.CHAT ? PANEL_VIEW.CONVERSATIONS : PANEL_VIEW.CHAT))
40
+ }, [])
41
+
42
+ const handleNewConversation = useCallback(() => {
43
+ chat.newConversation()
44
+ setView(PANEL_VIEW.CHAT)
45
+ }, [chat])
46
+
47
+ const handleSelectConversation = useCallback(
48
+ (id: string) => {
49
+ chat.selectConversation(id)
50
+ setView(PANEL_VIEW.CHAT)
51
+ },
52
+ [chat],
53
+ )
54
+
55
+ const isConversations = view === PANEL_VIEW.CONVERSATIONS
56
+
57
+ // The conversations view describes the list rather than the connection — the
58
+ // host-supplied status is about the agent, which is not what is on screen.
59
+ const headerStatus = isConversations
60
+ ? formatConversationSummary(chat.conversations.length, chat.isStreaming)
61
+ : status
62
+
63
+ return (
64
+ <section className={`mdk-agent-panel${className ? ` ${className}` : ''}`} aria-label={title}>
65
+ <PanelHeader
66
+ view={view}
67
+ title={isConversations ? AGENT_LABELS.conversations : title}
68
+ status={headerStatus}
69
+ actionsDisabled={chat.isStreaming}
70
+ onNewConversation={handleNewConversation}
71
+ onToggleConversations={toggleConversations}
72
+ onClose={onClose}
73
+ />
74
+
75
+ {isConversations
76
+ ? (
77
+ <ConversationList
78
+ conversations={chat.conversations}
79
+ activeId={chat.active?.id ?? null}
80
+ runningId={chat.isStreaming ? (chat.active?.id ?? null) : null}
81
+ onSelect={handleSelectConversation}
82
+ onCreate={handleNewConversation}
83
+ onDelete={chat.removeConversation}
84
+ />
85
+ )
86
+ : (
87
+ <>
88
+ <MessageList
89
+ messages={chat.messages}
90
+ live={chat.live}
91
+ toolLabels={toolLabels}
92
+ pendingApproval={chat.pendingApproval}
93
+ isDecisionPending={chat.isDecisionPending}
94
+ onDecide={chat.decide}
95
+ onRetry={chat.retry}
96
+ />
97
+ <Composer
98
+ disabled={!chat.canSend}
99
+ isStreaming={chat.isStreaming}
100
+ placeholder={placeholder}
101
+ focusKey={chat.active?.id ?? null}
102
+ onSend={chat.send}
103
+ onStop={chat.abort}
104
+ />
105
+ </>
106
+ )}
107
+ </section>
108
+ )
109
+ }
@@ -0,0 +1,63 @@
1
+ import type { ComponentPropsWithoutRef, JSX } from 'react'
2
+ import type { ExtraProps } from 'react-markdown'
3
+ import { useEffect, useRef, useState } from 'react'
4
+
5
+ /** How long the button confirms the copy before going back to its label. */
6
+ const COPIED_FEEDBACK_MS = 1500
7
+
8
+ export type CodeBlockProps = ComponentPropsWithoutRef<'pre'> & ExtraProps
9
+
10
+ /**
11
+ * A fenced code block, with a copy button.
12
+ *
13
+ * What the panel puts in fences is mostly shell commands and JSON an operator
14
+ * wants to run or paste somewhere, so copying it is the common next action.
15
+ *
16
+ * The text is read off the rendered node rather than reassembled from the hast
17
+ * tree: the highlighter has already replaced the block's children with token
18
+ * spans, and `textContent` is exactly the source again. The button is absent
19
+ * where the Clipboard API is — it needs a secure context, and a gateway on a site
20
+ * LAN is served over plain http.
21
+ */
22
+ export const CodeBlock = ({ children, node, ...rest }: CodeBlockProps): JSX.Element => {
23
+ const codeRef = useRef<HTMLPreElement>(null)
24
+ const [copied, setCopied] = useState(false)
25
+
26
+ const canCopy = typeof navigator?.clipboard?.writeText === 'function'
27
+
28
+ useEffect(() => {
29
+ if (!copied) return
30
+ const timer = setTimeout(() => setCopied(false), COPIED_FEEDBACK_MS)
31
+ return () => clearTimeout(timer)
32
+ }, [copied])
33
+
34
+ const copy = () => {
35
+ const text = codeRef.current?.textContent ?? ''
36
+ if (!text) return
37
+ void navigator.clipboard.writeText(text).then(
38
+ () => setCopied(true),
39
+ () => {
40
+ // Denied by permissions policy, or the document is not focused. Nothing
41
+ // to report — the code is still on screen and selectable.
42
+ },
43
+ )
44
+ }
45
+
46
+ return (
47
+ <div className="mdk-agent-code">
48
+ <pre ref={codeRef} {...rest}>{children}</pre>
49
+ {canCopy
50
+ ? (
51
+ <button
52
+ type="button"
53
+ className="mdk-agent-code__copy"
54
+ aria-label={copied ? 'Copied' : 'Copy code'}
55
+ onClick={copy}
56
+ >
57
+ {copied ? 'Copied' : 'Copy'}
58
+ </button>
59
+ )
60
+ : null}
61
+ </div>
62
+ )
63
+ }
@@ -0,0 +1,55 @@
1
+ import type { JSX } from 'react'
2
+ import { memo, useState } from 'react'
3
+
4
+ import { clipToBoundary } from '../core/markdown'
5
+ import { MarkdownText } from './markdown-text'
6
+
7
+ /**
8
+ * Where an answer stops being readable in a docked panel.
9
+ *
10
+ * The panel is 420×640, so a few thousand characters is already several screens
11
+ * of scrolling between one question and the next.
12
+ */
13
+ export const COLLAPSE_ABOVE_CHARS = 3000
14
+ /** How much of it is shown collapsed. */
15
+ export const COLLAPSED_CHARS = 1500
16
+
17
+ export type CollapsibleAnswerProps = {
18
+ text: string
19
+ /** While the turn streams the answer is never collapsed: growth has to stay visible. */
20
+ streaming?: boolean
21
+ }
22
+
23
+ /**
24
+ * An answer, collapsed behind a toggle when it is long enough to bury the rest of
25
+ * the transcript.
26
+ *
27
+ * Only ever collapsed once the turn has settled — mid-stream, hiding the tail
28
+ * would hide the very thing the operator is watching.
29
+ */
30
+ const CollapsibleAnswerView = ({ text, streaming = false }: CollapsibleAnswerProps): JSX.Element => {
31
+ const [expanded, setExpanded] = useState(false)
32
+
33
+ const isLong = !streaming && text.length > COLLAPSE_ABOVE_CHARS
34
+ const shown = isLong && !expanded ? clipToBoundary(text, COLLAPSED_CHARS) : text
35
+
36
+ return (
37
+ <>
38
+ <MarkdownText text={shown} streaming={streaming} />
39
+ {isLong
40
+ ? (
41
+ <button
42
+ type="button"
43
+ className="mdk-agent-markdown__more"
44
+ aria-expanded={expanded}
45
+ onClick={() => setExpanded(!expanded)}
46
+ >
47
+ {expanded ? 'Show less' : 'Show full answer'}
48
+ </button>
49
+ )
50
+ : null}
51
+ </>
52
+ )
53
+ }
54
+
55
+ export const CollapsibleAnswer = memo(CollapsibleAnswerView)