cortena-ui 1.8.0 → 1.11.0
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.
- package/CHANGELOG.md +127 -0
- package/README.md +204 -13
- package/dist/agent-chat/mcp-app/bridge.d.ts +194 -0
- package/dist/agent-chat/mcp-app/bridge.js +327 -0
- package/dist/agent-chat/mcp-app/bridge.js.map +1 -0
- package/dist/agent-chat/mcp-app/frame.d.ts +64 -0
- package/dist/agent-chat/mcp-app/frame.js +195 -0
- package/dist/agent-chat/mcp-app/frame.js.map +1 -0
- package/dist/agent-chat/mcp-app/index.d.ts +8 -0
- package/dist/agent-chat/mcp-app/render-tool-call.d.ts +47 -0
- package/dist/agent-chat/mcp-app/render-tool-call.js +77 -0
- package/dist/agent-chat/mcp-app/render-tool-call.js.map +1 -0
- package/dist/agent-chat/mcp-app/resource.d.ts +148 -0
- package/dist/agent-chat/mcp-app/resource.js +172 -0
- package/dist/agent-chat/mcp-app/resource.js.map +1 -0
- package/dist/agent-chat/mcp-app/sandbox.d.ts +90 -0
- package/dist/agent-chat/mcp-app/sandbox.js +177 -0
- package/dist/agent-chat/mcp-app/sandbox.js.map +1 -0
- package/dist/agent-chat/mcp-app/theme.d.ts +33 -0
- package/dist/agent-chat/mcp-app/theme.js +157 -0
- package/dist/agent-chat/mcp-app/theme.js.map +1 -0
- package/dist/agent-chat/session.d.ts +40 -0
- package/dist/agent-chat/session.js +264 -6
- package/dist/agent-chat/session.js.map +1 -1
- package/dist/agent-chat/store.d.ts +46 -0
- package/dist/agent-chat/store.js +176 -13
- package/dist/agent-chat/store.js.map +1 -1
- package/dist/agent-chat.d.ts +10 -3
- package/dist/agent-chat.js +9 -3
- package/dist/components/agent-chat.d.ts +39 -2
- package/dist/components/agent-chat.js +212 -24
- package/dist/components/agent-chat.js.map +1 -1
- package/dist/components/chip.d.ts +1 -1
- package/dist/components/markdown.js +32 -38
- package/dist/components/markdown.js.map +1 -1
- package/dist/components/rich-text-editor/extensions.d.ts +43 -0
- package/dist/components/rich-text-editor/extensions.js +278 -0
- package/dist/components/rich-text-editor/extensions.js.map +1 -0
- package/dist/components/rich-text-editor/index.d.ts +6 -0
- package/dist/components/rich-text-editor/markdown-bridge.d.ts +81 -0
- package/dist/components/rich-text-editor/markdown-bridge.js +618 -0
- package/dist/components/rich-text-editor/markdown-bridge.js.map +1 -0
- package/dist/components/rich-text-editor/rich-text-editor.d.ts +109 -0
- package/dist/components/rich-text-editor/rich-text-editor.js +357 -0
- package/dist/components/rich-text-editor/rich-text-editor.js.map +1 -0
- package/dist/components/rich-text-editor/toolbar.d.ts +27 -0
- package/dist/components/rich-text-editor/toolbar.js +222 -0
- package/dist/components/rich-text-editor/toolbar.js.map +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +5 -1
- package/dist/lib/prose.js +85 -0
- package/dist/lib/prose.js.map +1 -0
- package/dist/rich-text-editor.d.ts +7 -0
- package/dist/rich-text-editor.js +6 -0
- package/package.json +20 -2
- package/src/agent-chat/mcp-app/bridge.ts +455 -0
- package/src/agent-chat/mcp-app/frame.tsx +332 -0
- package/src/agent-chat/mcp-app/index.ts +56 -0
- package/src/agent-chat/mcp-app/render-tool-call.tsx +175 -0
- package/src/agent-chat/mcp-app/resource.ts +273 -0
- package/src/agent-chat/mcp-app/sandbox.ts +244 -0
- package/src/agent-chat/mcp-app/theme.ts +184 -0
- package/src/agent-chat/session.ts +341 -5
- package/src/agent-chat/store.ts +279 -9
- package/src/components/agent-chat.tsx +388 -70
- package/src/components/markdown.tsx +29 -172
- package/src/components/rich-text-editor/extensions.ts +328 -0
- package/src/components/rich-text-editor/index.ts +19 -0
- package/src/components/rich-text-editor/markdown-bridge.ts +791 -0
- package/src/components/rich-text-editor/rich-text-editor.tsx +607 -0
- package/src/components/rich-text-editor/toolbar.tsx +281 -0
- package/src/entries/agent-chat.ts +56 -1
- package/src/entries/rich-text-editor.ts +53 -0
- package/src/index.ts +2 -0
- package/src/lib/prose.ts +149 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,133 @@
|
|
|
3
3
|
Notable changes per release. Versions before 1.6.0 are recorded in the git log
|
|
4
4
|
and in `../../CONSUMING.md`; this file starts where the changelog does.
|
|
5
5
|
|
|
6
|
+
## 1.11.0
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **`RichTextEditor`** (DESIGN-54). Rich text editing, which nothing in the
|
|
11
|
+
fleet had. KeyStone needs it for vision documents, PRDs and requirement
|
|
12
|
+
documents; Tasks needs it to be able to replace a `<textarea>` it chose on
|
|
13
|
+
purpose. TipTap on ProseMirror, React bindings, value is Markdown (GFM) in
|
|
14
|
+
and out. Exported from the barrel and from its own subpath entry,
|
|
15
|
+
`cortena-ui/rich-text-editor` — TipTap and ProseMirror are 619 kB and MCP app
|
|
16
|
+
HTML is capped at 512 kB, so `test/bundle-probe.test.mjs` now asserts that no
|
|
17
|
+
other entry carries a byte of the editor engine (`button` from the barrel is
|
|
18
|
+
still 37.7 kB and pulls none of it).
|
|
19
|
+
- **The round trip is the feature.** An untouched document serialises
|
|
20
|
+
byte-identically — same line endings, same trailing whitespace — and
|
|
21
|
+
editing one paragraph changes one paragraph. Tasks uses a plain textarea
|
|
22
|
+
because a rich editor "would reformat text nobody touched and land it as a
|
|
23
|
+
spurious revision"; that constraint is a requirement of this component.
|
|
24
|
+
`markdown-bridge.ts` is mdast-based and source-preserving rather than
|
|
25
|
+
`tiptap-markdown`, which serialises through Turndown and renormalises
|
|
26
|
+
everything it touches. Asserted over an 18-file corpus in
|
|
27
|
+
`test/fixtures/rich-text/`.
|
|
28
|
+
- **Supported subset**: paragraphs, h2–h4, bold, italic, strikethrough,
|
|
29
|
+
inline code, links, ordered/bullet/task lists, blockquote, fenced code, GFM
|
|
30
|
+
tables, horizontal rule, images by URL. Anything outside it — front matter,
|
|
31
|
+
HTML blocks, footnotes, display maths, h1, h5, h6 — is preserved as a raw
|
|
32
|
+
Markdown block, never dropped.
|
|
33
|
+
- **Slots, not features**: `mentionProvider`, `selectionActions`,
|
|
34
|
+
`renderChip`. A mention is stored as `[@Priya](mention:user-42)`, so it is
|
|
35
|
+
still Markdown. Editor JSON and HTML never leave the component.
|
|
36
|
+
- **Controlled `value` with an explicit `version`**: a new revision arriving
|
|
37
|
+
while the author is focused does not clobber them; `onExternalChange` fires
|
|
38
|
+
with an `apply` for a conflict banner.
|
|
39
|
+
- Toolbar on the existing `Toolbar` primitive, keyboard shortcuts, and the
|
|
40
|
+
Markdown input rules (`## `, `- `, `1. `, `> `, backticks).
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- The prose class map moved to `src/lib/prose.ts` and is now shared by
|
|
45
|
+
`Markdown` and `RichTextEditor`. No visual change — the strings are the same
|
|
46
|
+
ones — but the read view and the edit view can no longer drift apart, which
|
|
47
|
+
is what keeps a document from shifting when it enters edit mode.
|
|
48
|
+
|
|
49
|
+
## 1.10.0
|
|
50
|
+
|
|
51
|
+
1.9.0 was never released: it was published by mistake and has been deprecated.
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
|
|
55
|
+
- **Screens inside the pop-up** (DESIGN-78). An extension's tool can answer
|
|
56
|
+
with its own screen — a `ui://` resource — and until now only cortenaweb
|
|
57
|
+
could show it; the pop-up inside the extension had no host. The host is now
|
|
58
|
+
in this package, on the `cortena-ui/agent-chat` entry:
|
|
59
|
+
- `McpAppFrame` — the sandboxed iframe (`allow-scripts allow-forms`, never
|
|
60
|
+
`allow-same-origin`), the `srcdoc` with the CSP written ahead of the app's
|
|
61
|
+
markup (`default-src 'none'`, no `unsafe-eval`, `form-action 'none'`), the
|
|
62
|
+
JSON-RPC bridge (`ui/initialize`, `ui/message`, `tools/call`,
|
|
63
|
+
`ui/update-model-context`, size and theme), the source and origin check
|
|
64
|
+
on every message, the height clamp (120–720 px), the `ui/message` bound
|
|
65
|
+
(`MCP_APP_MAX_MESSAGE_CHARS`, 4,000 chars; longer is refused with
|
|
66
|
+
`-32602`, since that text reaches the model) and the theme push. It holds
|
|
67
|
+
no credential: `callTool({ name, arguments, extensionId, lane })` is
|
|
68
|
+
supplied by the mounting app. A name that visibly belongs to another
|
|
69
|
+
extension (`mcp__notes__x`, `notes.note.delete`) is refused with `-32602`
|
|
70
|
+
before the relay is reached; a bare name (`task_get`, and equally
|
|
71
|
+
`broker_invoke`, `exec`, `read_file`) names no extension, so the frame
|
|
72
|
+
does not claim to confine it — it arrives as `lane: "bare"` beside
|
|
73
|
+
`extensionId`, for a relay that resolves it on that extension and nowhere
|
|
74
|
+
else. A resource another extension owns, a non-HTML type or a document
|
|
75
|
+
over
|
|
76
|
+
`MCP_APP_MAX_DOCUMENT_CHARS` (512,000; exported so a caller can make the
|
|
77
|
+
bound its own later, PLATFORM-82) is drawn as nothing and reported through
|
|
78
|
+
`onRefused`. Design tokens go in as `tokensCss` (inlined) or `tokensUrl`
|
|
79
|
+
(linked; its origin joins `style-src`/`font-src`); nothing is fetched
|
|
80
|
+
implicitly.
|
|
81
|
+
- `renderMcpAppToolCall({ extensionId, callTool, onMessage, … })` — a
|
|
82
|
+
`renderToolCall` for one extension. A screen is drawn only when the
|
|
83
|
+
*producing tool* can be named as this extension's — `mcp__<extensionId>__*`,
|
|
84
|
+
or `broker_invoke` whose `source` argument is this extension — **and** the
|
|
85
|
+
result carries that extension's embedded `ui://` resource (with
|
|
86
|
+
`structuredContent` from the result, or from
|
|
87
|
+
`_meta.cortena.structuredContent` on the block, as a reopened chat carries
|
|
88
|
+
it). A result is not a permission: an `exec` or a `read_file` whose output
|
|
89
|
+
happens to embed a `ui://` block keeps the ordinary card. `isEligible`
|
|
90
|
+
replaces the allowlist for a host whose tools are spelled another way.
|
|
91
|
+
Anything else returns `undefined` and the default card is drawn. Same type
|
|
92
|
+
as the seam in every mode.
|
|
93
|
+
- `AgentChatPopup` and owned-mode `AgentChat` take `renderToolCall` as
|
|
94
|
+
hosted mode does — the prop was on the shared base already; this release
|
|
95
|
+
pins it with a type-level test that the 1.7.0 mount shapes still compile.
|
|
96
|
+
See "Screens inside the pop-up" in `README.md`.
|
|
97
|
+
- **The pop-up keeps every turn, and a screen can answer back** (DESIGN-80).
|
|
98
|
+
The pop-up used to hold the current turn only: `send` emptied the tool
|
|
99
|
+
cards, so an extension's screen was unmounted by the very turn it asked
|
|
100
|
+
for. It now behaves like the main chat.
|
|
101
|
+
- **A turn's tool record is its own.** Each turn keeps its steps, its cards
|
|
102
|
+
and its screens, and they are drawn in the transcript **under the answer
|
|
103
|
+
that turn produced** rather than in a strip at the foot that the next run
|
|
104
|
+
clears. A screen from three turns ago is still on the page and still
|
|
105
|
+
running. `AgentChatState` gains `turns?: AgentChatTurn[]` — optional, so a
|
|
106
|
+
host projecting its own store onto `UseAgentChatResult` compiles and
|
|
107
|
+
behaves exactly as before — with `TURN_LIMIT` (20) turns kept.
|
|
108
|
+
- **A reopened chat gets the record back, not just the prose.**
|
|
109
|
+
`parseHistory(records)` returns the messages *and* the per-turn tool
|
|
110
|
+
record behind them: the calls (`tool_use`, `toolCall`, `serverToolUse` —
|
|
111
|
+
matched with the case and the underscores taken out, as cortenacore
|
|
112
|
+
matches them), their results, and the embedded `ui://` resource with its
|
|
113
|
+
payload read from `_meta.cortena.structuredContent` where the pod
|
|
114
|
+
persisted it. The budget is cortenaweb's: 1,100,000 characters for one
|
|
115
|
+
result (`MAX_HISTORY_TOOL_RESULT_CHARS`) and 2,000,000 for a whole load
|
|
116
|
+
(`MAX_HISTORY_SNAPSHOT_RESULT_CHARS`), spent newest turn first; past it a
|
|
117
|
+
result becomes a note giving its size, with the call id, the tool's name
|
|
118
|
+
and whether it failed all kept. `parseHistoryMessages` is unchanged.
|
|
119
|
+
- **`AgentToolRenderContext` gains `send(text)`**, and
|
|
120
|
+
`AgentChatToolsSlotProps` gains the same field for a host that replaces
|
|
121
|
+
the region and builds the context itself. Optional on both, so a host
|
|
122
|
+
that builds the render context as an object literal of its own — as
|
|
123
|
+
cortenaweb's step accordion does — compiles unchanged. `renderMcpAppToolCall` wires
|
|
124
|
+
`onMessage` to it by default, so `onMessage` is now optional: a
|
|
125
|
+
`ui/message` from a screen goes out as the MCP App action envelope —
|
|
126
|
+
`{ toolCallId, appUri, serverName, name: "message", payload: { text } }` —
|
|
127
|
+
attributed to the screen, with no user bubble, bounded by
|
|
128
|
+
`MCP_APP_MAX_MESSAGE_CHARS` (4,000) and refused while a run is live.
|
|
129
|
+
- A host that supplies `components.Tools` still owns the whole tool region:
|
|
130
|
+
it is handed every turn's entries, and the transcript draws none.
|
|
131
|
+
- See "Screens inside the pop-up" in `README.md`.
|
|
132
|
+
|
|
6
133
|
## 1.8.0
|
|
7
134
|
|
|
8
135
|
### Added
|
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ consumer's bundler getting tree-shaking right:
|
|
|
36
36
|
| `cortena-ui/a2ui` | the A2UI catalogue and renderer | most of the package, by design |
|
|
37
37
|
| `cortena-ui/sortable-list` | `SortableList`, `SortableHandle`, `arrayMove` | dnd-kit |
|
|
38
38
|
| `cortena-ui/agent-chat` | `AgentChatPopup`, `AgentChat`, `createAguiAgentChatClient` | the A2UI catalogue, plus `@ag-ui/client` (rxjs, zod 3, uuid, protobuf) |
|
|
39
|
+
| `cortena-ui/rich-text-editor` | `RichTextEditor`, `parseMarkdown`, `serializeMarkdown` | TipTap on ProseMirror, 619 kB — the heaviest entry in the package |
|
|
39
40
|
|
|
40
41
|
Each component has exactly one home, so the barrel re-exports every entry
|
|
41
42
|
without an ambiguous name. **Adding a component means adding it to its area
|
|
@@ -300,24 +301,101 @@ cortenacore.http.endpoints.agui.enabled = true
|
|
|
300
301
|
cortenacore.http.endpoints.controlPlane.enabled = true
|
|
301
302
|
```
|
|
302
303
|
|
|
303
|
-
###
|
|
304
|
+
### Screens inside the pop-up
|
|
304
305
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
306
|
+
An extension's tool may answer with the extension's own screen — an embedded
|
|
307
|
+
`ui://` resource whose body is HTML — and the pop-up draws it inside the
|
|
308
|
+
conversation, sandboxed, with a postMessage bridge (DESIGN-78; the protocol as
|
|
309
|
+
implemented is cortena's `docs/mcp-apps.md`). `renderToolCall` is the seam,
|
|
310
|
+
in every mode, and `renderMcpAppToolCall` answers it for one extension:
|
|
310
311
|
|
|
311
312
|
```tsx
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
client={client}
|
|
315
|
-
renderToolCall={({ entry, mcpAppUri }) =>
|
|
316
|
-
mcpAppUri ? <McpAppFrame uri={mcpAppUri} result={entry.output} /> : undefined
|
|
317
|
-
}
|
|
318
|
-
/>
|
|
313
|
+
const renderToolCall = renderMcpAppToolCall({ extensionId: "tasks", callTool });
|
|
314
|
+
<AgentChatPopup extension={extension} client={client} renderToolCall={renderToolCall} />
|
|
319
315
|
```
|
|
320
316
|
|
|
317
|
+
That is the whole round trip. A tool answers with a screen, the screen is
|
|
318
|
+
drawn under the answer it came with, a click inside it starts the next turn,
|
|
319
|
+
and the screen is still there when the answer arrives (DESIGN-80).
|
|
320
|
+
|
|
321
|
+
- **Only a tool that can be named as yours may draw a screen.** The default
|
|
322
|
+
allowlist is `mcp__<extensionId>__*`, and `broker_invoke` whose `source`
|
|
323
|
+
argument is your extension. A result is not a permission: an `exec` that
|
|
324
|
+
cat'd a fixture or a `read_file` over your test data answers with whatever
|
|
325
|
+
`ui://` block was in the file, and that stays an ordinary tool card. Pass
|
|
326
|
+
`isEligible(entry)` to replace the rule if your host's tools are spelled a
|
|
327
|
+
third way — you are replacing an allowlist, so you are deciding which tools
|
|
328
|
+
may put unreviewed HTML on the page.
|
|
329
|
+
- `callTool(call)` is **yours**: the relay that reaches your extension — your
|
|
330
|
+
own MCP relay, or cortenaweb's `/tools/invoke` — and whatever credential it
|
|
331
|
+
takes stays with it. The frame never sees a token. It hands you
|
|
332
|
+
`{ name, arguments, extensionId, lane }`, and the lane is the point:
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
type McpAppToolCaller = (call: {
|
|
336
|
+
name: string;
|
|
337
|
+
arguments: Record<string, unknown>;
|
|
338
|
+
extensionId: string;
|
|
339
|
+
lane: "mcp" | "broker" | "bare";
|
|
340
|
+
}) => Promise<unknown>;
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
`lane: "mcp"` (`mcp__tasks__x`) and `lane: "broker"` (`tasks.task.get`)
|
|
344
|
+
carry the extension in the name, and a name that carries a *different* one
|
|
345
|
+
(`mcp__notes__x`, `notes.note.delete`) is refused with `-32602` before your
|
|
346
|
+
relay is reached. `lane: "bare"` (`task_get`) carries nothing: `exec`,
|
|
347
|
+
`read_file` and `broker_invoke` are bare names too. **Resolve a bare name on
|
|
348
|
+
`extensionId` and nowhere else** — never forward it as a tool name of your
|
|
349
|
+
own — and an app cannot reach past its extension whatever it asks for.
|
|
350
|
+
- `onMessage(text)` is the app asking for something to be said, and **it is
|
|
351
|
+
wired for you**: with nothing passed, the text goes out through the seam's
|
|
352
|
+
own `send` as the next turn, attributed to the screen and not to the user —
|
|
353
|
+
nobody typed it, so no user bubble is drawn. What reaches the agent is the
|
|
354
|
+
MCP App action envelope (`{ toolCallId, appUri, serverName, name: "message",
|
|
355
|
+
payload: { text } }`), which is what lets an agent tell an event from your
|
|
356
|
+
board apart from one from another extension's card. It is bounded: the host
|
|
357
|
+
refuses text over 4,000 characters (`MCP_APP_MAX_MESSAGE_CHARS`) with
|
|
358
|
+
`-32602`, because this text reaches the model, and it is a no-op while a run
|
|
359
|
+
is already live — one run at a time, the same rule the composer follows.
|
|
360
|
+
Pass your own `onMessage` to do something else with it instead.
|
|
361
|
+
`onModelContext(ctx)` is its latest context for the next run.
|
|
362
|
+
- A call from a tool outside the allowlist, or a result carrying another
|
|
363
|
+
extension's screen, a bare link, or no screen at all, returns `undefined`
|
|
364
|
+
and the default card is drawn.
|
|
365
|
+
- Design tokens are not fetched implicitly. Pass `tokensCss` (the sheet,
|
|
366
|
+
inlined ahead of the app's markup) or `tokensUrl` (linked; its origin joins
|
|
367
|
+
`style-src` and `font-src`, never `connect-src`).
|
|
368
|
+
|
|
369
|
+
What the frame owns and no caller can widen: `sandbox="allow-scripts
|
|
370
|
+
allow-forms"` (never `allow-same-origin`, so the document runs in an opaque
|
|
371
|
+
origin with no storage and no reach into the page), a CSP written into the
|
|
372
|
+
`srcdoc` ahead of the app's markup (`default-src 'none'`, no `unsafe-eval`,
|
|
373
|
+
`form-action 'none'`; `_meta.ui.csp` on the resource may open named `https`
|
|
374
|
+
origins and nothing else), a message check that accepts only its own frame's
|
|
375
|
+
window at an opaque origin, a height the app asks for clamped to 120–720 px,
|
|
376
|
+
and a document bound of `MCP_APP_MAX_DOCUMENT_CHARS` (512,000) past which the
|
|
377
|
+
resource is refused. A refusal draws nothing and reports through
|
|
378
|
+
`onRefused(reason)`. `McpAppFrame` is exported for a host that finds the
|
|
379
|
+
resource itself.
|
|
380
|
+
|
|
381
|
+
**Where a screen lives.** Each turn keeps its own tool record — its steps, its
|
|
382
|
+
cards and its screens — and the record is drawn in the transcript under the
|
|
383
|
+
answer that turn produced, not in a strip at the foot that the next `send`
|
|
384
|
+
empties. So a screen from three turns ago is still on the page, still running,
|
|
385
|
+
and a turn sent from a screen does not unmount the screen that sent it. The
|
|
386
|
+
record of a turn survives a reopen too: `chat.history` carries the calls and
|
|
387
|
+
their results, so the cards, the worded steps and any screen come back with the
|
|
388
|
+
prose (`parseHistory`). Twenty turns are kept (`TURN_LIMIT`), and a reopen
|
|
389
|
+
spends at most 2 MB on tool results, newest turn first; past that a result
|
|
390
|
+
becomes a note that says how big it was, with its call, its name and its
|
|
391
|
+
outcome intact.
|
|
392
|
+
|
|
393
|
+
`AgentToolRenderContext` therefore carries `send(text)` — the handle
|
|
394
|
+
`renderMcpAppToolCall` wires `onMessage` to. A host that replaces the whole
|
|
395
|
+
tool region with `components.Tools` gets the same handle on its slot props and
|
|
396
|
+
passes it into `renderToolCall` itself; that host owns every card, so the
|
|
397
|
+
transcript draws none, and its `entries` are every turn's, not just the newest.
|
|
398
|
+
|
|
321
399
|
### Hosting the surface
|
|
322
400
|
|
|
323
401
|
`AgentChatPopup` owns its state. A host that already has state of its own —
|
|
@@ -354,6 +432,119 @@ names the session (the key is still written, so one window stays on one
|
|
|
354
432
|
session), `listSessions: false` for a host with its own session list, and
|
|
355
433
|
`mintSessionKey` for a host that binds sessions some other way.
|
|
356
434
|
|
|
435
|
+
## RichTextEditor
|
|
436
|
+
|
|
437
|
+
WYSIWYG editing whose value is Markdown (GFM), in and out. KeyStone needs it for
|
|
438
|
+
vision documents, PRDs and requirement documents; nothing else in the fleet has
|
|
439
|
+
rich text at all.
|
|
440
|
+
|
|
441
|
+
```tsx
|
|
442
|
+
import { RichTextEditor } from "cortena-ui/rich-text-editor";
|
|
443
|
+
|
|
444
|
+
<RichTextEditor
|
|
445
|
+
value={markdown}
|
|
446
|
+
version={revision}
|
|
447
|
+
aria-label="Vision document"
|
|
448
|
+
onChange={setMarkdown}
|
|
449
|
+
onExternalChange={({ apply }) => setConflict(() => apply)}
|
|
450
|
+
mentionProvider={({ trigger, query }) => search(trigger, query)}
|
|
451
|
+
selectionActions={({ text, editor }) => <AskAgent text={text} />}
|
|
452
|
+
/>
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
Editor JSON and HTML never leave the component. There is no `getHTML` escape
|
|
456
|
+
hatch on purpose: the moment a consumer stores ProseMirror JSON, the document
|
|
457
|
+
stops being a file that anything else can read.
|
|
458
|
+
|
|
459
|
+
### The round trip is the feature
|
|
460
|
+
|
|
461
|
+
Tasks deliberately uses a plain `<textarea>` for its section editor, because a
|
|
462
|
+
rich editor "would reformat text nobody touched and land it as a spurious
|
|
463
|
+
revision". That constraint is a requirement of this component, not a nice-to-have:
|
|
464
|
+
|
|
465
|
+
- **An untouched document serialises byte-identically.** Same bytes, including
|
|
466
|
+
line endings, trailing whitespace and a byte order mark.
|
|
467
|
+
- **Editing one paragraph changes one paragraph.** Every other block is written
|
|
468
|
+
back from the source it was parsed from.
|
|
469
|
+
|
|
470
|
+
`test/rich-text-markdown.test.tsx` asserts both over an 18-file corpus in
|
|
471
|
+
`test/fixtures/rich-text/`, covering nested lists, fences containing fences,
|
|
472
|
+
tables with escaped pipes, links with titles, CRLF and trailing whitespace.
|
|
473
|
+
|
|
474
|
+
### Why the bridge is written rather than adopted
|
|
475
|
+
|
|
476
|
+
`tiptap-markdown` is the obvious candidate and it fails the one requirement
|
|
477
|
+
above. It serialises by rendering the editor to HTML and running Turndown over
|
|
478
|
+
it, so the output is whatever Turndown's rules produce: bullets become `*`,
|
|
479
|
+
emphasis becomes `_`, tables are re-padded, and a document nobody edited comes
|
|
480
|
+
back different from how it went in. A component that cannot replace the Tasks
|
|
481
|
+
textarea has not delivered anything.
|
|
482
|
+
|
|
483
|
+
A plain mdast serializer does not fix it either. Any AST round trip normalises —
|
|
484
|
+
`mdast-util-to-markdown` has one way to write a bullet — and the moment the
|
|
485
|
+
author's file disagrees, every untouched paragraph in it is rewritten.
|
|
486
|
+
|
|
487
|
+
So `markdown-bridge.ts` is mdast-based **and** source-preserving. Parsing keeps
|
|
488
|
+
the exact source slice of every top-level block; a block whose ProseMirror JSON
|
|
489
|
+
is byte-for-byte what parsing produced is written back from that slice, and only
|
|
490
|
+
a block the user actually changed goes through `mdast-util-to-markdown`. The
|
|
491
|
+
dependencies are `mdast-util-from-markdown`, `mdast-util-to-markdown`,
|
|
492
|
+
`mdast-util-gfm` and `micromark-extension-frontmatter` (all MIT), which is the
|
|
493
|
+
same layer react-markdown already uses — `src/lib/prose.ts` is imported by both
|
|
494
|
+
views and reaches neither react-markdown nor ProseMirror.
|
|
495
|
+
|
|
496
|
+
### The supported subset, and what happens outside it
|
|
497
|
+
|
|
498
|
+
Paragraphs; headings h2–h4 (h1 is reserved for the record's own title); bold,
|
|
499
|
+
italic, strikethrough, inline code; links; ordered, bullet and task lists;
|
|
500
|
+
blockquote; fenced code; GFM tables; horizontal rule; images by URL.
|
|
501
|
+
|
|
502
|
+
Anything else — front matter, HTML blocks, footnotes, display maths, h1, h5, h6 —
|
|
503
|
+
becomes a **raw block**: a code-like node holding the original Markdown as text,
|
|
504
|
+
editable as raw Markdown and serialised with no escaping. It is never dropped.
|
|
505
|
+
The rule applies at the block boundary, so a paragraph containing a footnote
|
|
506
|
+
reference goes raw whole rather than losing the reference from an otherwise
|
|
507
|
+
editable paragraph.
|
|
508
|
+
|
|
509
|
+
Not in scope: maths rendering, collaborative cursors, comments UI, image upload,
|
|
510
|
+
syntax highlighting in fences.
|
|
511
|
+
|
|
512
|
+
### Slots, not features
|
|
513
|
+
|
|
514
|
+
`@` and `#` mean nothing to this component. `mentionProvider` is asked for items
|
|
515
|
+
and a chip is inserted; whether that resolves to a person, a task or a page is
|
|
516
|
+
the consumer's business. A mention is stored as `[@Priya](mention:user-42)`, so
|
|
517
|
+
the document is still ordinary Markdown and the editor reads the chip back on
|
|
518
|
+
the next open.
|
|
519
|
+
|
|
520
|
+
What survives in the **read** view is the label, not the link: `Markdown` passes
|
|
521
|
+
URLs through react-markdown's `urlTransform`, which drops any protocol it does
|
|
522
|
+
not know, so `[@Priya](mention:user-42)` renders as the text `@Priya` with no
|
|
523
|
+
href. A host that wants mentions to be clickable in the read view passes its own
|
|
524
|
+
`urlTransform` to `Markdown` and resolves `mention:` itself.
|
|
525
|
+
|
|
526
|
+
`selectionActions` renders whatever the host wants over the current selection,
|
|
527
|
+
and `renderChip` replaces how a mention looks in the menu.
|
|
528
|
+
|
|
529
|
+
### The controlled value and `version`
|
|
530
|
+
|
|
531
|
+
`value` is the document and `version` is the revision it belongs to. A new
|
|
532
|
+
revision arriving while the author is focused does **not** replace what they are
|
|
533
|
+
writing: `onExternalChange` fires with `{ value, version, apply }` so the
|
|
534
|
+
consumer can show a conflict banner and adopt it on the author's say-so. When
|
|
535
|
+
the author is not in the editor it is adopted silently. `version` defaults to
|
|
536
|
+
`value`, so a consumer that simply echoes `onChange` back still behaves — an
|
|
537
|
+
echo of our own output is never treated as a conflict.
|
|
538
|
+
|
|
539
|
+
### Styling
|
|
540
|
+
|
|
541
|
+
Prose comes from `src/lib/prose.ts`, the same class strings the `Markdown`
|
|
542
|
+
renderer paints the read view with, handed to TipTap as each node's
|
|
543
|
+
`HTMLAttributes.class`. That is the only reason a document does not shift when
|
|
544
|
+
it enters edit mode, and `test/rich-text-editor.test.tsx` compares the computed
|
|
545
|
+
font size, family, weight and line height of both components on the same source.
|
|
546
|
+
Changing a prose style means changing it in `prose.ts`, for both views at once.
|
|
547
|
+
|
|
357
548
|
## Column widths
|
|
358
549
|
|
|
359
550
|
`DataTable` lays the grid out `table-fixed`: every column is drawn at its
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
//#region src/agent-chat/mcp-app/bridge.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The host side of the MCP Apps postMessage bridge.
|
|
5
|
+
*
|
|
6
|
+
* Implements the host role of the MCP Apps specification dated 2026-01-26 —
|
|
7
|
+
* the one `@modelcontextprotocol/ext-apps` 1.7.5 publishes — with the method
|
|
8
|
+
* names and handshake verbatim, so an app built with the SDK's `App` class
|
|
9
|
+
* connects with no Cortena-specific code. Ported from cortenaweb's host
|
|
10
|
+
* (DESIGN-36); the spec strings are copied rather than imported so this
|
|
11
|
+
* package does not take `@modelcontextprotocol/sdk` and a zod copy into the
|
|
12
|
+
* browser bundle for one JSON-RPC dialogue, and the tests pin every one.
|
|
13
|
+
*
|
|
14
|
+
* ## Who the host will listen to
|
|
15
|
+
*
|
|
16
|
+
* Three checks, in order, before a frame's message is looked at at all:
|
|
17
|
+
*
|
|
18
|
+
* 1. `event.source === iframe.contentWindow` — the one thing a frame cannot
|
|
19
|
+
* forge. Any other frame on the page fails here.
|
|
20
|
+
* 2. `event.origin === "null"` — an app runs without `allow-same-origin`, so
|
|
21
|
+
* its origin is opaque and serialises as the string `"null"`. A message
|
|
22
|
+
* from a real origin did not come from a sandboxed app, whatever its
|
|
23
|
+
* `source` claims.
|
|
24
|
+
* 3. The body must be a JSON-RPC 2.0 envelope naming a method the host serves.
|
|
25
|
+
*
|
|
26
|
+
* Replies go out with `targetOrigin: "*"`, which is correct rather than lazy:
|
|
27
|
+
* an opaque origin cannot be named, so `"*"` is the only value that reaches
|
|
28
|
+
* it, and nothing sent is a secret — the tool result the app's own extension
|
|
29
|
+
* produced, and the theme.
|
|
30
|
+
*/
|
|
31
|
+
/** `LATEST_PROTOCOL_VERSION` in the MCP Apps spec. */
|
|
32
|
+
export declare const MCP_APP_PROTOCOL_VERSION = "2026-01-26";
|
|
33
|
+
/** Every method name the spec defines, as the host sees them. */
|
|
34
|
+
export declare const MCP_APP_METHOD: {
|
|
35
|
+
readonly INITIALIZE: "ui/initialize";
|
|
36
|
+
readonly OPEN_LINK: "ui/open-link";
|
|
37
|
+
readonly MESSAGE: "ui/message";
|
|
38
|
+
readonly UPDATE_MODEL_CONTEXT: "ui/update-model-context";
|
|
39
|
+
readonly REQUEST_DISPLAY_MODE: "ui/request-display-mode";
|
|
40
|
+
readonly CALL_TOOL: "tools/call";
|
|
41
|
+
readonly PING: "ping";
|
|
42
|
+
readonly INITIALIZED: "ui/notifications/initialized";
|
|
43
|
+
readonly SIZE_CHANGED: "ui/notifications/size-changed";
|
|
44
|
+
readonly TOOL_INPUT: "ui/notifications/tool-input";
|
|
45
|
+
readonly TOOL_RESULT: "ui/notifications/tool-result";
|
|
46
|
+
readonly HOST_CONTEXT_CHANGED: "ui/notifications/host-context-changed";
|
|
47
|
+
};
|
|
48
|
+
/** JSON-RPC error codes the host returns. */
|
|
49
|
+
export declare const MCP_APP_ERROR: {
|
|
50
|
+
readonly METHOD_NOT_FOUND: -32601;
|
|
51
|
+
readonly INVALID_PARAMS: -32602;
|
|
52
|
+
readonly INTERNAL: -32603;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* The longest `ui/message` the host will carry.
|
|
56
|
+
*
|
|
57
|
+
* `ui/message` becomes a turn in the conversation, so the text an app sends
|
|
58
|
+
* is text that goes to the model — and an app is HTML nobody reviewed. An
|
|
59
|
+
* unbounded field is a way to spend a context window, and a paste of a
|
|
60
|
+
* hundred kilobytes is not a sentence a screen wanted said. Long enough for
|
|
61
|
+
* any real request; short enough that it cannot be the payload.
|
|
62
|
+
*/
|
|
63
|
+
export declare const MCP_APP_MAX_MESSAGE_CHARS = 4000;
|
|
64
|
+
export interface McpAppHostContext {
|
|
65
|
+
theme?: "light" | "dark";
|
|
66
|
+
styles?: {
|
|
67
|
+
variables?: Record<string, string | undefined>;
|
|
68
|
+
};
|
|
69
|
+
displayMode?: "inline";
|
|
70
|
+
availableDisplayModes?: Array<"inline">;
|
|
71
|
+
containerDimensions?: {
|
|
72
|
+
maxWidth?: number;
|
|
73
|
+
maxHeight?: number;
|
|
74
|
+
};
|
|
75
|
+
locale?: string;
|
|
76
|
+
timeZone?: string;
|
|
77
|
+
userAgent?: string;
|
|
78
|
+
platform?: "web";
|
|
79
|
+
/** Cortena extension: where the app can fetch the design tokens, when the host serves them. */
|
|
80
|
+
cortena?: {
|
|
81
|
+
tokensCssUrl?: string;
|
|
82
|
+
};
|
|
83
|
+
[key: string]: unknown;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* How a scoped `tools/call` reaches the app's own extension. Supplied by the
|
|
87
|
+
* mounting app; the frame never sees a token.
|
|
88
|
+
*
|
|
89
|
+
* The call arrives as one envelope rather than a name and arguments, because
|
|
90
|
+
* a name on its own cannot be confined. Two of the three spellings an app may
|
|
91
|
+
* use carry the extension (`mcp__tasks__x`, `tasks.task.get`) and are refused
|
|
92
|
+
* before they reach here when the extension is not this one; the third is
|
|
93
|
+
* **bare** (`task_get`), and a bare name names nothing — `broker_invoke`,
|
|
94
|
+
* `exec` and `read_file` are bare names too. So `extensionId` and `lane`
|
|
95
|
+
* travel with it: a relay handed `lane: "bare"` must resolve `name` on
|
|
96
|
+
* `extensionId` and nowhere else, and a relay that scopes on `extensionId`
|
|
97
|
+
* cannot be tricked into forwarding an app's bare name as a tool of its own.
|
|
98
|
+
*/
|
|
99
|
+
export type McpAppToolCaller = (call: {
|
|
100
|
+
name: string;
|
|
101
|
+
arguments: Record<string, unknown>;
|
|
102
|
+
extensionId: string;
|
|
103
|
+
lane: "mcp" | "broker" | "bare";
|
|
104
|
+
}) => Promise<unknown>;
|
|
105
|
+
export interface McpAppBridgeOptions {
|
|
106
|
+
/** The frame the app runs in. Its `contentWindow` is the only valid source. */
|
|
107
|
+
iframe: HTMLIFrameElement;
|
|
108
|
+
/** The extension the app belongs to; every `tools/call` is confined to it. */
|
|
109
|
+
extensionId: string;
|
|
110
|
+
/** `_meta.ui` the resource declared, echoed in `hostCapabilities.sandbox`. */
|
|
111
|
+
sandbox?: {
|
|
112
|
+
permissions?: unknown;
|
|
113
|
+
csp?: unknown;
|
|
114
|
+
};
|
|
115
|
+
/** Arguments the agent passed. Sent as `ui/notifications/tool-input`. */
|
|
116
|
+
toolArguments?: Record<string, unknown>;
|
|
117
|
+
/** The whole tool result. Sent as `ui/notifications/tool-result`. */
|
|
118
|
+
toolResult?: unknown;
|
|
119
|
+
hostContext: McpAppHostContext;
|
|
120
|
+
/** Present only when the app's own extension can be reached. */
|
|
121
|
+
callTool?: McpAppToolCaller;
|
|
122
|
+
/** `ui/message`: the text the app wants said in the conversation. */
|
|
123
|
+
onMessage?: (text: string) => void;
|
|
124
|
+
/** The app asked for a height. */
|
|
125
|
+
onSizeChanged?: (size: {
|
|
126
|
+
width?: number;
|
|
127
|
+
height?: number;
|
|
128
|
+
}) => void;
|
|
129
|
+
/** The handshake completed. */
|
|
130
|
+
onInitialized?: () => void;
|
|
131
|
+
/** `ui/open-link`. Defaults to a `noopener` `window.open`. */
|
|
132
|
+
onOpenLink?: (url: string) => boolean;
|
|
133
|
+
/** The app's latest `ui/update-model-context`. */
|
|
134
|
+
onModelContext?: (context: {
|
|
135
|
+
content?: unknown[];
|
|
136
|
+
structuredContent?: unknown;
|
|
137
|
+
}) => void;
|
|
138
|
+
/** For tests. Defaults to `window`. */
|
|
139
|
+
listenTarget?: Pick<Window, "addEventListener" | "removeEventListener">;
|
|
140
|
+
}
|
|
141
|
+
export declare class McpAppHostBridge {
|
|
142
|
+
private readonly options;
|
|
143
|
+
private readonly listenTarget;
|
|
144
|
+
private hostContext;
|
|
145
|
+
private toolResult;
|
|
146
|
+
private initialized;
|
|
147
|
+
private closed;
|
|
148
|
+
private readonly onMessage;
|
|
149
|
+
constructor(options: McpAppBridgeOptions);
|
|
150
|
+
start(): void;
|
|
151
|
+
close(): void;
|
|
152
|
+
/** Did the app complete `ui/initialize` + `ui/notifications/initialized`? */
|
|
153
|
+
get isInitialized(): boolean;
|
|
154
|
+
/**
|
|
155
|
+
* Capabilities the host advertises. `serverTools` only when there is a
|
|
156
|
+
* caller: an app that sees it absent knows not to try, which is the spec's
|
|
157
|
+
* own way of saying "not here" and better than letting it call and fail.
|
|
158
|
+
*/
|
|
159
|
+
private capabilities;
|
|
160
|
+
/**
|
|
161
|
+
* A replaced tool result for a screen already running.
|
|
162
|
+
*
|
|
163
|
+
* The same call re-run answers with the same document, so the frame is not
|
|
164
|
+
* remounted and the app never repeats its handshake — which is where the
|
|
165
|
+
* payload is otherwise sent. Without this the screen would keep drawing the
|
|
166
|
+
* first answer's data for the rest of the conversation.
|
|
167
|
+
*/
|
|
168
|
+
setToolResult(result: unknown): void;
|
|
169
|
+
/** Push a changed theme (or anything else) to a running app. */
|
|
170
|
+
setHostContext(patch: McpAppHostContext): void;
|
|
171
|
+
private post;
|
|
172
|
+
private notify;
|
|
173
|
+
private respond;
|
|
174
|
+
private fail;
|
|
175
|
+
/** The right window, and an origin that proves it is sandboxed. */
|
|
176
|
+
private isFromApp;
|
|
177
|
+
private handleMessage;
|
|
178
|
+
private dispatch;
|
|
179
|
+
/**
|
|
180
|
+
* `tools/call` from the app: scoped, not proxied. The app may call tools on
|
|
181
|
+
* *its own* extension and nothing else — see `scopeMcpAppToolCall` for the
|
|
182
|
+
* rule and why. A name that visibly belongs to another extension is refused
|
|
183
|
+
* here, before the relay is reached; a bare name reaches the relay wrapped
|
|
184
|
+
* in `extensionId` and `lane: "bare"`, so the relay resolves it on this
|
|
185
|
+
* extension rather than taking it for a tool of its own. Whatever the relay
|
|
186
|
+
* answers goes back as the result, and a throw becomes a tool result the
|
|
187
|
+
* app can show.
|
|
188
|
+
*/
|
|
189
|
+
private handleCallTool;
|
|
190
|
+
/** The tool's arguments and result, once the app says it is listening. */
|
|
191
|
+
private sendToolPayload;
|
|
192
|
+
}
|
|
193
|
+
//#endregion
|
|
194
|
+
//# sourceMappingURL=bridge.d.ts.map
|