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.
Files changed (75) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/README.md +204 -13
  3. package/dist/agent-chat/mcp-app/bridge.d.ts +194 -0
  4. package/dist/agent-chat/mcp-app/bridge.js +327 -0
  5. package/dist/agent-chat/mcp-app/bridge.js.map +1 -0
  6. package/dist/agent-chat/mcp-app/frame.d.ts +64 -0
  7. package/dist/agent-chat/mcp-app/frame.js +195 -0
  8. package/dist/agent-chat/mcp-app/frame.js.map +1 -0
  9. package/dist/agent-chat/mcp-app/index.d.ts +8 -0
  10. package/dist/agent-chat/mcp-app/render-tool-call.d.ts +47 -0
  11. package/dist/agent-chat/mcp-app/render-tool-call.js +77 -0
  12. package/dist/agent-chat/mcp-app/render-tool-call.js.map +1 -0
  13. package/dist/agent-chat/mcp-app/resource.d.ts +148 -0
  14. package/dist/agent-chat/mcp-app/resource.js +172 -0
  15. package/dist/agent-chat/mcp-app/resource.js.map +1 -0
  16. package/dist/agent-chat/mcp-app/sandbox.d.ts +90 -0
  17. package/dist/agent-chat/mcp-app/sandbox.js +177 -0
  18. package/dist/agent-chat/mcp-app/sandbox.js.map +1 -0
  19. package/dist/agent-chat/mcp-app/theme.d.ts +33 -0
  20. package/dist/agent-chat/mcp-app/theme.js +157 -0
  21. package/dist/agent-chat/mcp-app/theme.js.map +1 -0
  22. package/dist/agent-chat/session.d.ts +40 -0
  23. package/dist/agent-chat/session.js +264 -6
  24. package/dist/agent-chat/session.js.map +1 -1
  25. package/dist/agent-chat/store.d.ts +46 -0
  26. package/dist/agent-chat/store.js +176 -13
  27. package/dist/agent-chat/store.js.map +1 -1
  28. package/dist/agent-chat.d.ts +10 -3
  29. package/dist/agent-chat.js +9 -3
  30. package/dist/components/agent-chat.d.ts +39 -2
  31. package/dist/components/agent-chat.js +212 -24
  32. package/dist/components/agent-chat.js.map +1 -1
  33. package/dist/components/chip.d.ts +1 -1
  34. package/dist/components/markdown.js +32 -38
  35. package/dist/components/markdown.js.map +1 -1
  36. package/dist/components/rich-text-editor/extensions.d.ts +43 -0
  37. package/dist/components/rich-text-editor/extensions.js +278 -0
  38. package/dist/components/rich-text-editor/extensions.js.map +1 -0
  39. package/dist/components/rich-text-editor/index.d.ts +6 -0
  40. package/dist/components/rich-text-editor/markdown-bridge.d.ts +81 -0
  41. package/dist/components/rich-text-editor/markdown-bridge.js +618 -0
  42. package/dist/components/rich-text-editor/markdown-bridge.js.map +1 -0
  43. package/dist/components/rich-text-editor/rich-text-editor.d.ts +109 -0
  44. package/dist/components/rich-text-editor/rich-text-editor.js +357 -0
  45. package/dist/components/rich-text-editor/rich-text-editor.js.map +1 -0
  46. package/dist/components/rich-text-editor/toolbar.d.ts +27 -0
  47. package/dist/components/rich-text-editor/toolbar.js +222 -0
  48. package/dist/components/rich-text-editor/toolbar.js.map +1 -0
  49. package/dist/index.d.ts +6 -1
  50. package/dist/index.js +5 -1
  51. package/dist/lib/prose.js +85 -0
  52. package/dist/lib/prose.js.map +1 -0
  53. package/dist/rich-text-editor.d.ts +7 -0
  54. package/dist/rich-text-editor.js +6 -0
  55. package/package.json +20 -2
  56. package/src/agent-chat/mcp-app/bridge.ts +455 -0
  57. package/src/agent-chat/mcp-app/frame.tsx +332 -0
  58. package/src/agent-chat/mcp-app/index.ts +56 -0
  59. package/src/agent-chat/mcp-app/render-tool-call.tsx +175 -0
  60. package/src/agent-chat/mcp-app/resource.ts +273 -0
  61. package/src/agent-chat/mcp-app/sandbox.ts +244 -0
  62. package/src/agent-chat/mcp-app/theme.ts +184 -0
  63. package/src/agent-chat/session.ts +341 -5
  64. package/src/agent-chat/store.ts +279 -9
  65. package/src/components/agent-chat.tsx +388 -70
  66. package/src/components/markdown.tsx +29 -172
  67. package/src/components/rich-text-editor/extensions.ts +328 -0
  68. package/src/components/rich-text-editor/index.ts +19 -0
  69. package/src/components/rich-text-editor/markdown-bridge.ts +791 -0
  70. package/src/components/rich-text-editor/rich-text-editor.tsx +607 -0
  71. package/src/components/rich-text-editor/toolbar.tsx +281 -0
  72. package/src/entries/agent-chat.ts +56 -1
  73. package/src/entries/rich-text-editor.ts +53 -0
  74. package/src/index.ts +2 -0
  75. 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
- ### The MCP App frame
304
+ ### Screens inside the pop-up
304
305
 
305
- Rendering an extension's own screen means a sandboxed iframe, a `srcdoc` CSP, a
306
- JSON-RPC postMessage bridge and a `tools/invoke` proxy holding a credential —
307
- host concerns, not component ones. `renderToolCall` is the seam: it is handed
308
- each tool call and the `ui://` resource its result carries, and a node it
309
- returns replaces the default card.
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
- <AgentChatPopup
313
- extension={extension}
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