dsh-markdown-bubble 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DDDMUC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,184 @@
1
+ # dsh-markdown-bubble
2
+
3
+ **DeepSeek Harness 用户消息 Markdown 渲染插件 —— 你发出去的每一条消息,都按 Markdown 渲染。** 标题、列表、加粗、行内代码、引用、代码块、表格与公式不再以源码形态躺在气泡里,而是经宿主自己的 Markdown 管线(`MarkdownText`)呈现;引用 chip、附件、时间/复制操作条与其它插件注入的按钮全部原样保留。实现方式是官方 keyed 槽位 `conversation.chat.node` 的 `user` / `steering` 座位替换,不修改 DSH 源码,不改写任何会话日志。
4
+
5
+ [中文](#中文) · [English](#english)
6
+
7
+ ---
8
+
9
+ ## 中文
10
+
11
+ <div align="center">
12
+ <img src="https://raw.githubusercontent.com/DDDMUC/dsh-markdown-bubble/main/docs/screenshots/01-markdown-bubble.png" alt="用户消息气泡按 Markdown 渲染" width="820" />
13
+ <br>
14
+ <sub>▲ 发出的 Markdown 按格式渲染;单换行保留为换行,代码块带宿主同款语法高亮卡</sub>
15
+ <br><br>
16
+ <img src="https://raw.githubusercontent.com/DDDMUC/dsh-markdown-bubble/main/docs/screenshots/02-action-strip.png" alt="操作条上的复制、删除、重跑、编辑按钮" width="520" />
17
+ <br>
18
+ <sub>▲ 操作条原样保留:时间、复制,以及 dsh-delete-turn / dsh-rerun-turn / dsh-edit-turn 注入的按钮</sub>
19
+ </div>
20
+
21
+ ### 为什么需要它
22
+
23
+ DSH 的用户消息气泡按原样显示你输入的文字(`white-space: pre-wrap`):粘贴一段 README、写一段带列表的提示词、贴一段代码,都要先在脑子里解一遍 Markdown。助手回复有完整的 Markdown 管线,你自己的消息却没有。
24
+
25
+ 这个插件把你这一侧的气泡也接到同一条管线上:
26
+
27
+ - 标题、列表、有序列表、任务列表、加粗 / 斜体 / 删除线、行内代码;
28
+ - 围栏代码块(宿主同款语法高亮与复制 / 换行工具条)、引用、分割线;
29
+ - 表格(宽表可横向滚动)、链接、图片、KaTeX 公式、脚注;
30
+ - 单换行保留为换行(GFM 语义会把 `第一行\n第二行` 合并成一段,聊天里没有人希望这样)。
31
+
32
+ ### 特性
33
+
34
+ - **官方座位替换** —— 经 `ctx.slots` 在 `conversation.chat.node` 的 `user` / `steering` 两个 keyed 座位上以 `priority: -1` 遮蔽宿主渲染器;停用/卸载即恢复宿主自己的气泡,不改源码、不动日志
35
+ - **宿主同款渲染** —— 不自己造 Markdown 管线,直接调用 `@deepseek-ai/dsh-client-ui-primitives` 的 `MarkdownText`:与助手回复同一套解析、高亮、表格、公式与代码卡
36
+ - **引用 chip 保留** —— 宿主投影函数 `projectUserText` 照常调用,返回的片段按 chip / 纯文本拆分:chip 仍是可点击的文件 / 技能 chip,纯文本段逐个进 Markdown
37
+ - **操作条与三方插件兼容** —— 时间 + 复制按钮用官方 primitives 重建,操作条保留 `_actions` / `_action` 类名后缀与 `data-clock` 锚点;dsh-edit-turn、dsh-delete-turn、dsh-rerun-turn 的按钮注入照常工作
38
+ - **附件与补充块** —— 图片走官方 `conversation.message.images` 座位(`renderMessageImages`),文件卡、引用摘要、`JsonBlock` 补充块与宿主同款
39
+ - **软换行保留** —— 仅对围栏代码之外的续行补 GFM 两空格硬换行;代码块一个字节不动,缩进代码与表格行不误伤
40
+ - **出错兜底** —— 单条消息的渲染错误被错误边界接住,回退为宿主的原始投影,不会连累整条座位被槽位机制摘除
41
+ - **中英双语** 随系统语言切换(代码卡文案复用宿主 `chat` 字典)
42
+
43
+ ### 安装
44
+
45
+ ```sh
46
+ # npm 安装(web profile)
47
+ dsh plugin --profile web add dsh-markdown-bubble
48
+
49
+ # 本地工作区(开发)
50
+ dsh plugin --profile web add link:/path/to/dsh-markdown-bubble
51
+ ```
52
+
53
+ `dsh plugin add` 会把依赖写进 profile 并把包追加到 `dsh.profile.bundles`。实测宿主随即扫描到新行、浏览器端热挂载,无需重启;若你的宿主版本未热载,重启 DSH 进程后刷新页面即可。
54
+
55
+ ### 使用
56
+
57
+ 安装即生效,无命令、无设置项。发一条带 Markdown 的消息即可看到渲染结果:
58
+
59
+ ````markdown
60
+ # 标题
61
+
62
+ - 列表 **加粗**
63
+ - 第二项 `行内代码`
64
+
65
+ > 引用一行
66
+
67
+ ```js
68
+ const a = 1
69
+ ```
70
+ ````
71
+
72
+ 排队中的消息预览、输入框本身与 goal 面板保持纯文本(见「已知限制」)。
73
+
74
+ ### 工作原理
75
+
76
+ 1. **座位遮蔽**:浏览器半区 `ctx.slots.inject('conversation.chat.node', …)` 注册 `user` 与 `steering` 两个 keyed 条目,`priority: -1` 低于宿主默认的 `0`(同 key 同优先级会抛错,最低者渲染)。
77
+ 2. **内容拆分**:从节点数据取 `content`,按宿主 `UserStyleBubble` 的同一规则拆成文本 / 图片附件 / 文件卡 / 剩余块。
78
+ 3. **chip 感知的 Markdown 合成**:调用宿主投影 `projectUserText`,把返回片段分成 chip 与纯文本;纯文本段逐个经 `MarkdownText` 渲染,chip 原样成为可点击元素。
79
+ 4. **源码预处理**:CRLF 归一、会话回顾 wire 链接(`@[label](dsh-session:…)`)还原为显示名、围栏代码之外的续行补两空格硬换行。
80
+ 5. **外壳复用**:行 / 堆栈 / 气泡 / 文件卡的度量值逐条对齐宿主样式表(`MessageItem.module.css`、`MessageIconActions.module.css`),颜色全部走主题 token;操作条在 DOM 序中前置、用 flex `order` 还原视觉顺序——这样三方插件 `querySelector('[class*="_actions"]')` 的首个命中永远是消息操作条,而不是气泡内代码卡的工具条。
81
+
82
+ ### 已知限制
83
+
84
+ - **待确认气泡不覆盖**:本地回声与待发送 steering 由宿主 `PendingSubmissionBubble` / `PendingSteeringBubble` 直绘,不走 chat-node 座位,仍是纯文本;消息落盘后即切换为 Markdown。
85
+ - **队列预览不覆盖**:排队消息的一行预览与 goal 命令行保持纯文本。
86
+ - **行内 chip 的折衷**:chip 打断连排段落时,该段会拆成 chip 两边的块(与社区同类实现相同的取舍);chip 在段首/段尾的常见情形无缝。
87
+ - **皮肤边界**:若某个皮肤直接针对宿主 CSS-module 类名定制用户气泡样式,不会命中本插件的气泡;主题 token 与 `.dshmb-*` / `.dshmb_*` 自定义样式正常生效。
88
+ - **多行语义**:单换行按硬换行渲染;连续空格、Tab 缩进等 Markdown 忽略的空白会被折叠(代码块内除外)。
89
+
90
+ ### 兼容性
91
+
92
+ - 实测 DSH `0.2.0-rc.1`(web profile,Chromium)。依赖:keyed 槽位 `conversation.chat.node`、行锚点 `data-chat-flow-kind` / `data-pending-steering` / `data-submission-echo`、primitives 导出的 `MarkdownText` / `projectUserText` / `writeClipboard`。
93
+ - 与 `dsh-edit-turn`、`dsh-delete-turn`、`dsh-rerun-turn` 协同:三者的按钮注入基于 `[class*="_actions"]` / `[class*="_action"]`,本插件保留该契约;被 dsh-edit-turn 改写(rollback)的消息显示其自己的改写气泡,与本插件无关。
94
+ - 宿主半区为惰性入口(无运行时行为),全部逻辑在 `./client` 浏览器半区;不修改官方源码,不写私有事件类型。
95
+
96
+ ### 修复记录
97
+
98
+ - **0.1.1** — 修复两个引用 chip 之间只有空白时该空白被当作空 Markdown 块丢弃的问题:相邻 chip 不再粘成 `@a@b`,chip 之间的换行也按宿主 `pre-wrap` 气泡的样子保留为换行(空白段渲染为 `data-dshmb-gap` 的预换行节点,仍不产生空 Markdown 块);自建的操作条 / 复制按钮补上 `data-dshmb-actions` / `data-dshmb-action` 命名空间标记;座位优先级 `-1` 的宿主依据(宿主默认 0、最低者渲染、同优先级抛错)与三个兄弟插件依赖的锚点结构一并加进回归测试。
99
+
100
+ ### License
101
+
102
+ MIT
103
+
104
+ ---
105
+
106
+ ## English
107
+
108
+ **Markdown rendering for DeepSeek Harness sent messages — every message you send is rendered as Markdown.** Headings, lists, bold, inline code, quotes, code fences, tables and math stop sitting in the bubble as source text and render through the host's own Markdown pipeline (`MarkdownText`); reference chips, attachments, the clock/copy action strip and sibling plugins' injected buttons all stay exactly where they were. It works by shadowing the official `user` / `steering` keyed seats of `conversation.chat.node` — no DSH source changes, no session-log rewrites.
109
+
110
+ ### Why you need it
111
+
112
+ DSH draws your own messages verbatim (`white-space: pre-wrap`): paste a README, write a prompt with a list, drop in a code snippet, and you parse the Markdown in your head first. Assistant replies get the full pipeline; your side does not. This plugin puts your side on the same pipeline: headings, lists, task lists, bold/italic/strikethrough, inline code, fenced code blocks (with the host's syntax highlighting and copy/wrap toolbar), quotes, rules, tables (wide ones scroll), links, images, KaTeX math and footnotes — with single line breaks preserved as line breaks.
113
+
114
+ ### Features
115
+
116
+ - **Official seat replacement** — registers `user` and `steering` keyed entries in `conversation.chat.node` at `priority: -1` through `ctx.slots`; disabling or uninstalling restores the host's own bubble. No source edits, no log writes.
117
+ - **The host's own renderer** — calls `MarkdownText` from `@deepseek-ai/dsh-client-ui-primitives`, so user messages share the assistant pipeline: the same parsing, highlighting, tables, math and code cards.
118
+ - **Reference chips stay chips** — the host projection (`projectUserText`) is called as usual and split into chips and plain runs; chips keep their click targets, each plain run becomes a Markdown block.
119
+ - **Action strip and sibling-plugin compatibility** — clock and copy are rebuilt from exported primitives; the strip keeps the `_actions` / `_action` class suffixes and the `data-clock` anchor that dsh-edit-turn, dsh-delete-turn and dsh-rerun-turn search for.
120
+ - **Attachments and extra blocks** — images go through the official `conversation.message.images` seat (`renderMessageImages`); file cards, the reference summary and `JsonBlock` extras match the host.
121
+ - **Soft breaks preserved** — continuation lines outside fenced code get GFM's two-space hard break; code keeps every byte, indented code and table rows are left alone.
122
+ - **Crash containment** — a per-message error boundary falls back to the host's plain projection instead of having the slot machinery retire the whole seat.
123
+ - **Bilingual** copy follows the active locale (code-card chrome reuses the host `chat` dictionary).
124
+
125
+ ### Install
126
+
127
+ ```sh
128
+ # from npm (web profile)
129
+ dsh plugin --profile web add dsh-markdown-bubble
130
+
131
+ # local workspace (development)
132
+ dsh plugin --profile web add link:/path/to/dsh-markdown-bubble
133
+ ```
134
+
135
+ `dsh plugin add` writes the dependency into the profile and appends the package to `dsh.profile.bundles`. In testing the host picked the new row up live and the browser hot-mounted it with no restart; if your host build does not hot-load, restart DSH and reload the page.
136
+
137
+ ### Usage
138
+
139
+ Install and send Markdown — no command, no setting. A smoke test:
140
+
141
+ ````markdown
142
+ # Heading
143
+
144
+ - list with **bold**
145
+ - second item `inline code`
146
+
147
+ > a quote
148
+
149
+ ```js
150
+ const a = 1
151
+ ```
152
+ ````
153
+
154
+ Queued-message previews, the composer itself and the goal panel stay plain text (see limitations).
155
+
156
+ ### How it works
157
+
158
+ 1. **Seat shadowing** — the browser half registers `user` and `steering` keyed entries under `conversation.chat.node` at `priority: -1`, lower than the host's default `0` (same key at the same priority throws; the lowest renders).
159
+ 2. **Content split** — the node's `content` is split exactly as the host's `UserStyleBubble` does: text, image attachments, file cards, remaining blocks.
160
+ 3. **Chip-aware Markdown composition** — the host projection is split into chips and plain runs; each plain run renders through `MarkdownText`, chips stay clickable elements.
161
+ 4. **Source preparation** — CRLF normalization, session-recall wire links (`@[label](dsh-session:…)`) reduced to their display label, and two-space hard breaks on continuation lines outside fenced code.
162
+ 5. **Shell parity** — row/stack/bubble/file-card metrics are copied from the host sheets (`MessageItem.module.css`, `MessageIconActions.module.css`) while colors stay theme tokens; the action strip leads the row's DOM order with flex `order` restoring the visual order, so a sibling plugin's `querySelector('[class*="_actions"]')` always lands on the message bar — never on a code card's toolbar inside the bubble.
163
+
164
+ ### Known limitations
165
+
166
+ - **Pending bubbles are not covered**: the local submission echo and the pending steering bubble are drawn directly by the host (not through the chat-node seats) and stay plain text until the message lands.
167
+ - **Queue previews are not covered**: the one-line queue preview and goal command rows stay plain text.
168
+ - **Inline-chip trade-off**: a chip interrupting a running paragraph splits that paragraph into blocks around the chip (the same trade-off community implementations take); boundary chips are seamless.
169
+ - **Skin boundary**: a skin that styles the host's CSS-module bubble classes directly will not hit this plugin's bubble; theme tokens and `.dshmb-*` / `.dshmb_*` custom styles work.
170
+ - **Multi-line semantics**: single newlines render as hard breaks; whitespace Markdown ignores (runs of spaces, tab indentation) is collapsed outside code blocks.
171
+
172
+ ### Compatibility
173
+
174
+ - Verified against DSH `0.2.0-rc.1` (web profile, Chromium). Depends on the keyed `conversation.chat.node` slot, the `data-chat-flow-kind` / `data-pending-steering` / `data-submission-echo` row anchors, and the primitives exports `MarkdownText` / `projectUserText` / `writeClipboard`.
175
+ - Works alongside `dsh-edit-turn`, `dsh-delete-turn` and `dsh-rerun-turn`: their injection relies on `[class*="_actions"]` / `[class*="_action"]`, which this plugin preserves. A message rewritten (rolled back) by dsh-edit-turn shows that plugin's own replacement bubble, which is out of scope here.
176
+ - The host half is an inert entry (no runtime behavior); everything lives in the `./client` browser bundle. No official source is modified and no private event type is written.
177
+
178
+ ### Fixes
179
+
180
+ - **0.1.1** — fixed whitespace-only plain runs between two reference chips being dropped as an empty Markdown block: adjacent chips no longer glue into `@a@b`, and a newline between two chips stays a line break the way the host's `pre-wrap` bubble shows it (the run renders as a `data-dshmb-gap` pre-wrap node, still never an empty Markdown block); the self-created action strip and copy button now carry the `data-dshmb-actions` / `data-dshmb-action` namespace markers; and the host evidence behind `priority: -1` (host default 0, lowest renders, same priority throws) plus the anchor structure the three sibling plugins rely on are covered by regression tests.
181
+
182
+ ### License
183
+
184
+ MIT
@@ -0,0 +1,12 @@
1
+ # dsh-markdown-bubble bundle patch: inserts the plugin row into the profile roster.
2
+ #
3
+ # The host half is inert: how a sent message is drawn is a browser-side
4
+ # projection, so the work lives entirely in the exported "./client" bundle.
5
+ #
6
+ # The entry name is the BARE package name on purpose: the client-module scanner
7
+ # treats it as a package specifier and resolves `<name>/package.json`, so a
8
+ # subpath entry would be cached as "not a client package" and the browser half
9
+ # would silently never load.
10
+ - insert:
11
+ - id: dsh-markdown-bubble
12
+ name: 'dsh-markdown-bubble'
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "dsh-markdown-bubble",
3
+ "version": "0.1.1",
4
+ "description": "Markdown rendering for DeepSeek Harness sent user messages: every message bubble renders lists, code, tables, quotes and inline formatting through the host's own Markdown pipeline, while queues, previews and the composer keep their plain projection.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./client": "./src/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "cordis.patch.yml",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "keywords": [
19
+ "deepseek-harness",
20
+ "dsh",
21
+ "dsh-plugin",
22
+ "markdown",
23
+ "user-message",
24
+ "bubble",
25
+ "chat",
26
+ "render"
27
+ ],
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/DDDMUC/dsh-markdown-bubble.git"
32
+ },
33
+ "engines": {
34
+ "node": ">=22"
35
+ },
36
+ "scripts": {
37
+ "test": "node --test \"test/*.test.js\""
38
+ },
39
+ "dsh": {
40
+ "bundle": {
41
+ "patch": "./cordis.patch.yml"
42
+ },
43
+ "client": {
44
+ "platform": "web",
45
+ "inject": [
46
+ "@deepseek-ai/dsh-client-locale",
47
+ "@deepseek-ai/dsh-client-ui-primitives"
48
+ ]
49
+ },
50
+ "engines": {
51
+ "dsh": ">=0.2.0-rc.1"
52
+ }
53
+ }
54
+ }
package/src/client.js ADDED
@@ -0,0 +1,730 @@
1
+ // dsh-markdown-bubble — browser half.
2
+ //
3
+ // What this does
4
+ // --------------
5
+ // The chat view draws a durable sent message through the keyed
6
+ // `conversation.chat.node` seats `user` and `steering` (one host component
7
+ // serves both), which project the raw logged text with `projectUserText` and
8
+ // paint it under `white-space: pre-wrap` — markdown shown as typed. This
9
+ // plugin shadows those two seats at a lower priority and re-renders the same
10
+ // bubble with the host's own markdown pipeline, `MarkdownText`.
11
+ //
12
+ // The replacement keeps what the host keeps:
13
+ //
14
+ // - Reference chips stay chips. The host projection is called (it is frozen,
15
+ // not replaceable, but perfectly callable) and its pieces are split into
16
+ // chips and plain runs; chips render unchanged, plain runs go through
17
+ // MarkdownText. A chip inside a running paragraph splits that paragraph
18
+ // into blocks around it — boundary chips are the common case.
19
+ // - The action strip survives. The host's own bar (clock + copy) is rebuilt
20
+ // from exported primitives, and its container keeps the `_actions` /
21
+ // `_action` class suffixes and the `data-clock` attribute that sibling
22
+ // plugins (dsh-edit-turn, dsh-delete-turn, dsh-rerun-turn) anchor their
23
+ // injected buttons on — verified against their sources.
24
+ // - Attachments, the reference summary and `JsonBlock` extras render as the
25
+ // host renders them, from the same node data and the same owner callbacks.
26
+ //
27
+ // The pending steering bubble and the local submission echo do not pass
28
+ // through these seats (they are not chat nodes yet), so they keep the host's
29
+ // plain projection. Queue previews and the goal panel keep it too.
30
+ //
31
+ // Soft line breaks are preserved: GFM would join "line one\nline two" into one
32
+ // paragraph, but a chat box Enter is a line break. Plain runs are prepared
33
+ // with two-space hard breaks outside fenced code before they reach the
34
+ // renderer.
35
+ //
36
+ // The bundle follows the client-modules contract: it registers a factory with
37
+ // `window.__ModuleLoader__.load` and returns the plugin exports.
38
+ window.__ModuleLoader__.load({
39
+ id: 'dsh-markdown-bubble',
40
+ factory: (require) => {
41
+ var module = { exports: {} }
42
+ var exports = module.exports
43
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
44
+
45
+ const react = require('react')
46
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
47
+
48
+ /** Plugin identity and diagnostics key. */
49
+ const NS = 'dsh-markdown-bubble'
50
+
51
+ /** Keep in sync with package.json and src/index.js. */
52
+ const PLUGIN_VERSION = '0.1.1'
53
+
54
+ /** Presence marker the live verifier reads. */
55
+ const DEBUG_KEY = '__DSH_MARKDOWN_BUBBLE__'
56
+
57
+ /** Style tag id claimed by this plugin (HMR and dispose remove it by id). */
58
+ const CSS_TAG = NS + '/markdown-bubble.css'
59
+
60
+ /** Chat-node seats this renderer replaces; the host serves both with one view. */
61
+ const SEATS = ['user', 'steering']
62
+
63
+ // Seat priority: the host's default is 0, the plugin sits below it, and it
64
+ // must not merely be different — the same key at the same priority is not a
65
+ // shadow, it is an error.
66
+ //
67
+ // Evidence (verified against the installed host, DSH 0.2.0-rc.1):
68
+ // - the host registers these two keys with no priority field at all
69
+ // (@deepseek-ai/dsh-client-ui-chat/lib/client.js:6830-6839), so its entry
70
+ // carries the default;
71
+ // - @deepseek-ai/dsh-client-ui-slots/lib/index.js reads
72
+ // options.priority ?? 0 (:167), keeps the ledger sorted by priority
73
+ // ascending (:221) and answers a keyed cell with the FIRST live entry of
74
+ // that key (:278-293) — the lowest priority renders;
75
+ // - the same key at the same priority throws instead of shadowing
76
+ // (:177-178), so 0 would break the page, and a positive value would lose
77
+ // the seat to the host.
78
+ // -1 is therefore the smallest step that shadows the host and stays clear
79
+ // of it.
80
+ const SEAT_PRIORITY = -1
81
+
82
+ const { createElement: h, Fragment } = react
83
+
84
+ // --- source preparation ---------------------------------------------------
85
+
86
+ /** Session recalls log the wire form `@[label](dsh-session:...)`; the label alone is what a chip shows. */
87
+ const SESSION_WIRE_RE = /@\[([^\]\n]+)\]\(dsh-session:[^)\s]+\)/gu
88
+
89
+ /**
90
+ * A fenced-code opening line: up to three spaces and a run of three or
91
+ * more backticks or tildes. Returns the fence descriptor, or null.
92
+ * @param line - one source line.
93
+ * @returns the fence descriptor or null.
94
+ */
95
+ function openingFence(line) {
96
+ const match = /^ {0,3}(`{3,}|~{3,})/.exec(line)
97
+ return match === null ? null : { char: match[1][0], length: match[1].length }
98
+ }
99
+
100
+ /**
101
+ * A fenced-code closing line for one fence: the same character, a run at
102
+ * least as long as the opening one, and nothing but trailing whitespace
103
+ * after it (CommonMark forbids a closing info string).
104
+ * @param line - one source line.
105
+ * @param fence - the opening fence descriptor.
106
+ * @returns whether this line closes the fence.
107
+ */
108
+ function isClosingFence(line, fence) {
109
+ const match = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line)
110
+ return match !== null && match[1][0] === fence.char && match[1].length >= fence.length
111
+ }
112
+
113
+ /**
114
+ * Walk the source line by line, rewriting only lines that sit outside a
115
+ * fenced code block. Fenced code is left byte-for-byte alone — its
116
+ * whitespace is the point — and its opening and closing lines are never
117
+ * rewritten either.
118
+ * @param text - message text.
119
+ * @param visit - (line, nextLine | undefined) => replacement line.
120
+ * @returns the rewritten source.
121
+ */
122
+ function walkOutsideFences(text, visit) {
123
+ if (!text.includes('\n')) return visit(text, undefined)
124
+ const lines = text.split('\n')
125
+ let fence = null
126
+ for (let index = 0; index < lines.length; index += 1) {
127
+ const line = lines[index]
128
+ if (fence !== null) {
129
+ if (isClosingFence(line, fence)) fence = null
130
+ continue
131
+ }
132
+ const opener = openingFence(line)
133
+ if (opener !== null) {
134
+ fence = opener
135
+ continue
136
+ }
137
+ lines[index] = visit(line, lines[index + 1])
138
+ }
139
+ return lines.join('\n')
140
+ }
141
+
142
+ /**
143
+ * The two-space GFM hard break a line needs when a soft break would
144
+ * otherwise join it to the next: applied only to content lines that
145
+ * continue (blank lines end the paragraph), and never to indented code
146
+ * (which preserves its own line structure).
147
+ * @param line - current line.
148
+ * @param next - the following line, absent on the last line.
149
+ * @returns the line with its hard-break suffix when it needs one.
150
+ */
151
+ function hardBreakLine(line, next) {
152
+ if (line === '' || next === undefined || next.trim() === '') return line
153
+ if (/^ {4}/.test(line) || line.startsWith('\t')) return line
154
+ return line + ' '
155
+ }
156
+
157
+ /**
158
+ * Preserve the single line breaks a reader typed.
159
+ *
160
+ * GFM treats one newline inside a paragraph as a soft break (a space), but
161
+ * this bubble renders what a person typed into a chat box, where Enter is
162
+ * a line break. Blanket `pre-wrap` is not an option: it would also expose
163
+ * the source whitespace markdown ignores (list indentation, continuation
164
+ * spacing). So the source itself is prepared for the renderer.
165
+ * @param text - newline-normalized message text.
166
+ * @returns the source with soft breaks promoted to hard breaks.
167
+ */
168
+ function preserveHardBreaks(text) {
169
+ return walkOutsideFences(text, hardBreakLine)
170
+ }
171
+
172
+ /**
173
+ * Replace a session-recall wire link with its display label. The markdown
174
+ * renderer only allows http(s)/mailto destinations, so an untranslated
175
+ * `dsh-session:` link would lose its anchor anyway — this just makes the
176
+ * plain-text fallback identical to what the chip showed. Fenced code is
177
+ * left alone: a pasted wire link there is code, not a recall.
178
+ * @param text - raw logged message text.
179
+ * @returns the text with wire links unwrapped.
180
+ */
181
+ function unwrapSessionReferences(text) {
182
+ return walkOutsideFences(text, (line) => line.replace(SESSION_WIRE_RE, '$1'))
183
+ }
184
+
185
+ /**
186
+ * The message text as markdown source: CRLF normalized, session wire
187
+ * links unwrapped, soft breaks promoted to hard breaks — each applied
188
+ * only outside fenced code.
189
+ * @param text - raw logged message text.
190
+ * @returns markdown source for the renderer.
191
+ */
192
+ function toMarkdown(text) {
193
+ const normalized = text.replace(/\r\n?/gu, '\n')
194
+ return walkOutsideFences(normalized, (line, next) => hardBreakLine(line.replace(SESSION_WIRE_RE, '$1'), next))
195
+ }
196
+
197
+ // --- content projection ----------------------------------------------------
198
+
199
+ /**
200
+ * Split content blocks exactly as the host's UserStyleBubble does: joined
201
+ * text, image/file attachments, and the remainder.
202
+ * @param content - the node's content blocks.
203
+ * @returns text, attachments and rest blocks.
204
+ */
205
+ function contentParts(content) {
206
+ const texts = []
207
+ const attachments = []
208
+ const rest = []
209
+ for (const block of content) {
210
+ if (block !== null && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
211
+ texts.push(block.text)
212
+ } else if (block !== null && typeof block === 'object' && block.type === 'image' && block.attachment !== undefined) {
213
+ attachments.push({ type: 'image', image: { attachment: block.attachment } })
214
+ } else if (block !== null && typeof block === 'object' && block.type === 'file' && block.attachment !== undefined) {
215
+ attachments.push({ type: 'file', file: block.attachment })
216
+ } else {
217
+ rest.push(block)
218
+ }
219
+ }
220
+ return { text: texts.join(''), attachments, rest }
221
+ }
222
+
223
+ /** Is this projected piece a reference chip (vs a plain text run)? */
224
+ function isChip(piece) {
225
+ return react.isValidElement(piece) && piece.props !== null && piece.props['data-ref-chip'] !== undefined
226
+ }
227
+
228
+ /** Plain-run text sits directly inside the projection's run spans. */
229
+ function runText(piece) {
230
+ return react.isValidElement(piece) && typeof piece.props.children === 'string' ? piece.props.children : ''
231
+ }
232
+
233
+ /** The host projection, called in a guard: it is the fallback of last resort. */
234
+ function plainProjection(text, referenceLabels, skillNames, references) {
235
+ try {
236
+ return primitives.projectUserText(text, referenceLabels, skillNames, 'skill', references)
237
+ } catch (error) {
238
+ console.warn('[' + NS + '] the host user-text projection failed; showing raw text', error)
239
+ return h('span', { className: 'dshmb-plain' }, text)
240
+ }
241
+ }
242
+
243
+ /**
244
+ * A whitespace-only plain run: the gap between two chips, or the leading /
245
+ * trailing padding the host's own pre-wrap bubble shows. It carries no
246
+ * markdown, so it is never a markdown block, but it is not nothing either —
247
+ * see the flush() comment in composeUserMarkdown.
248
+ * @param text - the run's whitespace.
249
+ * @param key - react key.
250
+ * @returns the gap span.
251
+ */
252
+ function gapSpan(text, key) {
253
+ return h('span', { className: 'dshmb-plain', 'data-dshmb-gap': '1', key }, text)
254
+ }
255
+
256
+ /** One markdown block for one contiguous plain run. */
257
+ function markdownBlock(source, labels, key) {
258
+ return h(
259
+ 'div',
260
+ { className: 'dshmb-md', 'data-dshmb-md': '1', key },
261
+ h(primitives.MarkdownText, { text: source, labels }),
262
+ )
263
+ }
264
+
265
+ /**
266
+ * Compose the bubble body: reference chips stay chips, each contiguous
267
+ * plain run becomes a markdown block — except a whitespace-only run, which
268
+ * stays a pre-wrap text node so two chips never glue together. A chip inside
269
+ * a running paragraph splits that paragraph around the chip; boundary chips
270
+ * read seamlessly.
271
+ * @param text - the message text.
272
+ * @param labels - markdown chrome labels.
273
+ * @param referenceLabels - session recall labels the host associates with the message.
274
+ * @param skillNames - skill names the host loaded for the message.
275
+ * @param references - file/skill open callbacks for clickable chips.
276
+ * @returns the composed body, or null for empty text.
277
+ */
278
+ function composeUserMarkdown(text, labels, referenceLabels, skillNames, references) {
279
+ if (typeof text !== 'string' || text === '') return null
280
+ const projected = plainProjection(text, referenceLabels, skillNames, references)
281
+ const pieces =
282
+ react.isValidElement(projected) && projected.type === Fragment && Array.isArray(projected.props.children)
283
+ ? projected.props.children
284
+ : [projected]
285
+ if (!pieces.some(isChip)) return markdownBlock(toMarkdown(text), labels, 'md')
286
+
287
+ const out = []
288
+ let run = []
289
+ const flush = () => {
290
+ if (run.length === 0) return
291
+ const raw = run.join('')
292
+ const source = toMarkdown(raw)
293
+ if (source.trim() === '') {
294
+ // Whitespace is not markdown, so it never becomes a block — but it is
295
+ // not nothing either. Between two chips it is the only thing keeping
296
+ // them apart: dropping it renders "@a@b" where the host shows
297
+ // "@a @b", and a newline between two chips (a real line break in the
298
+ // host's pre-wrap bubble) would collapse onto one line. The host
299
+ // projection keeps that run as a plain span with the same whitespace;
300
+ // this renderer keeps it as the pre-wrap stand-in instead of deleting
301
+ // it.
302
+ const gap = raw.replace(/\r\n?/gu, '\n')
303
+ if (gap !== '') out.push(gapSpan(gap, 'gap' + out.length))
304
+ } else {
305
+ out.push(markdownBlock(source, labels, 'md' + out.length))
306
+ }
307
+ run = []
308
+ }
309
+ for (const piece of pieces) {
310
+ if (isChip(piece)) {
311
+ flush()
312
+ out.push(h(Fragment, { key: 'chip' + out.length }, piece))
313
+ } else {
314
+ run.push(runText(piece))
315
+ }
316
+ }
317
+ flush()
318
+ if (out.length === 0) return null
319
+ return out.length === 1 ? out[0] : h(Fragment, null, out)
320
+ }
321
+
322
+ // --- markdown chrome --------------------------------------------------------
323
+
324
+ /**
325
+ * Markdown chrome labels from the chat translator, matching the host's own
326
+ * `markdownLabels` wiring so fence cards carry identical copy.
327
+ * @param t - the chat locale seat.
328
+ * @returns labels for code fences and footnotes.
329
+ */
330
+ function buildMarkdownLabels(t) {
331
+ return {
332
+ code: {
333
+ copyLabel: t('copy'),
334
+ copiedLabel: t('copied'),
335
+ toolbarLabels: {
336
+ codeLabel: t('codeBlock.title'),
337
+ wrapLabel: t('codeBlock.wrap'),
338
+ unwrapLabel: t('codeBlock.unwrap'),
339
+ },
340
+ },
341
+ footnotes: t('markdown.footnotes'),
342
+ }
343
+ }
344
+
345
+ // --- clock ------------------------------------------------------------------
346
+
347
+ /** @param value - non-negative integer. @returns two-digit string. */
348
+ function pad2(value) {
349
+ return String(value).padStart(2, '0')
350
+ }
351
+
352
+ /**
353
+ * Midnight epoch of the current local day.
354
+ * @param now - epoch milliseconds.
355
+ * @returns the day's local start.
356
+ */
357
+ function startOfLocalDay(now) {
358
+ const date = new Date(now)
359
+ date.setHours(0, 0, 0, 0)
360
+ return date.getTime()
361
+ }
362
+
363
+ /**
364
+ * Milliseconds until the next local midnight.
365
+ * @param now - epoch milliseconds.
366
+ * @returns the remaining interval.
367
+ */
368
+ function msUntilNextLocalMidnight(now) {
369
+ const next = new Date(startOfLocalDay(now))
370
+ next.setDate(next.getDate() + 1)
371
+ return Math.max(1, next.getTime() - now)
372
+ }
373
+
374
+ /**
375
+ * Local calendar-day epoch that advances at each local midnight, so a
376
+ * message clock flips from `HH:mm` to a date without a re-render trigger.
377
+ * @returns midnight ms for the current local day.
378
+ */
379
+ function useLocalDay() {
380
+ const [day, setDay] = react.useState(() => startOfLocalDay(Date.now()))
381
+ react.useEffect(() => {
382
+ const timer = setTimeout(() => setDay(startOfLocalDay(Date.now())), msUntilNextLocalMidnight(Date.now()))
383
+ return () => clearTimeout(timer)
384
+ }, [day])
385
+ return day
386
+ }
387
+
388
+ /**
389
+ * The host's message clock: `HH:mm` for today, the `clock.md` date plus
390
+ * the clock earlier this year, the `clock.ymd` form for other years.
391
+ * @param time - message time in epoch milliseconds.
392
+ * @param t - the chat locale seat.
393
+ * @param now - current epoch milliseconds (already local-day bucketed by the caller).
394
+ * @returns the display string.
395
+ */
396
+ function formatMessageClock(time, t, now) {
397
+ const date = new Date(time)
398
+ const reference = new Date(now)
399
+ const clock = `${pad2(date.getHours())}:${pad2(date.getMinutes())}`
400
+ if (date.getFullYear() === reference.getFullYear() && date.getMonth() === reference.getMonth() && date.getDate() === reference.getDate()) return clock
401
+ const params = { y: date.getFullYear(), m: date.getMonth() + 1, d: date.getDate() }
402
+ return `${date.getFullYear() === reference.getFullYear() ? t('clock.md', params) : t('clock.ymd', params)} ${clock}`
403
+ }
404
+
405
+ // --- pieces -----------------------------------------------------------------
406
+
407
+ /**
408
+ * Contain a renderer crash to one message: the bubble falls back to the
409
+ * host's plain projection instead of crashing the seat, which the slot
410
+ * machinery would answer by retiring the whole renderer.
411
+ */
412
+ class MarkdownBoundary extends react.Component {
413
+ constructor(props) {
414
+ super(props)
415
+ this.state = { failed: false }
416
+ }
417
+
418
+ static getDerivedStateFromError() {
419
+ return { failed: true }
420
+ }
421
+
422
+ componentDidCatch(error) {
423
+ console.warn('[' + NS + '] markdown rendering failed; falling back to the plain projection', error)
424
+ }
425
+
426
+ render() {
427
+ return this.state.failed ? this.props.fallback : this.props.children
428
+ }
429
+ }
430
+
431
+ /**
432
+ * The host's action strip for a sent message: clock + copy. The container
433
+ * keeps the `_actions` class suffix and the `data-clock` attribute that
434
+ * dsh-edit-turn / dsh-delete-turn / dsh-rerun-turn search for when they
435
+ * inject their own buttons, and the copy button keeps the `_action`
436
+ * suffix their ordering heuristic reads.
437
+ * @param props - message text, time, and the chat translator.
438
+ * @returns the action strip.
439
+ */
440
+ // Anchor fidelity, live-verified on the running instance: this strip is the
441
+ // row's first DOM child, its direct children are [clock span, copy button]
442
+ // exactly like the host's own MessageIconActions
443
+ // (@deepseek-ai/dsh-client-ui-chat/lib/client.js:1157-1172), the copy button
444
+ // keeps the _action suffix on a direct child (the host's Tooltip adds no
445
+ // wrapper box) and data-clock stays "start". dsh-edit-turn, dsh-delete-turn
446
+ // and dsh-rerun-turn therefore find the bar with
447
+ // row.querySelector('[class*="_actions"]') and their insertion point with
448
+ // bar.querySelectorAll('button') without knowing a single class hash of the
449
+ // host's.
450
+ function MessageActions({ text, time, t }) {
451
+ const day = useLocalDay()
452
+ const [copied, setCopied] = react.useState(false)
453
+ const pending = react.useRef(false)
454
+ const timer = react.useRef(null)
455
+
456
+ react.useEffect(
457
+ () => () => {
458
+ pending.current = false
459
+ if (timer.current !== null) clearTimeout(timer.current)
460
+ },
461
+ [],
462
+ )
463
+
464
+ const onCopy = react.useCallback(() => {
465
+ if (copied || pending.current) return
466
+ pending.current = true
467
+ primitives.writeClipboard(text).then((ok) => {
468
+ pending.current = false
469
+ if (!ok) return
470
+ setCopied(true)
471
+ timer.current = window.setTimeout(() => {
472
+ timer.current = null
473
+ setCopied(false)
474
+ }, 1000)
475
+ })
476
+ }, [copied, text])
477
+
478
+ const clock =
479
+ time === undefined ? null : h('span', { className: 'dshmb-timeStart' }, formatMessageClock(time, t, day))
480
+ const label = copied ? t('copied') : t('copy')
481
+
482
+ return h(
483
+ 'div',
484
+ { className: 'dshmb_actions', 'data-clock': 'start', 'data-dshmb-actions': '1' },
485
+ clock,
486
+ h(
487
+ primitives.Tooltip,
488
+ { label, side: 'bottom' },
489
+ h(
490
+ 'button',
491
+ { type: 'button', className: 'dshmb_action', 'aria-label': label, 'data-dshmb-action': '1', onClick: onCopy },
492
+ copied ? h(primitives.IconCheckOutlineRegular, {}) : h(primitives.IconCopyOutlineRegular, {}),
493
+ ),
494
+ ),
495
+ )
496
+ }
497
+
498
+ /**
499
+ * One attachment row entry: an image goes through the host's durable
500
+ * image renderer, a file renders as the host's file card.
501
+ * @param props - the attachment entry and the owner's image renderer.
502
+ * @returns the row entry.
503
+ */
504
+ function AttachmentEntry({ attachment, renderMessageImages, compactImages }) {
505
+ if (attachment.type === 'image') {
506
+ return h(Fragment, null, renderMessageImages({ images: [attachment.image], align: 'end', compact: compactImages }))
507
+ }
508
+ const file = attachment.file ?? {}
509
+ const meta = [primitives.fileExtension(file.name).toUpperCase().slice(0, 8), primitives.fileSizeText(file.bytes)]
510
+ .filter(Boolean)
511
+ .join(' ')
512
+ return h(
513
+ 'span',
514
+ { className: 'dshmb-fileCard', title: file.name },
515
+ h(primitives.FileTypeIcon, { path: file.name, className: 'dshmb-fileIcon' }),
516
+ h(
517
+ 'span',
518
+ { className: 'dshmb-fileContent' },
519
+ h('span', { className: 'dshmb-fileName' }, file.name),
520
+ h('span', { className: 'dshmb-fileMeta' }, meta),
521
+ ),
522
+ )
523
+ }
524
+
525
+ /**
526
+ * The markdown-rendered user message bubble — the keyed `user`/`steering`
527
+ * seat replacement. The host serves both keys with one identical view and
528
+ * so does this component.
529
+ * @param props - keyed chat renderer seat for the `user`/`steering` kinds.
530
+ * @returns the right-aligned bubble with attachments, markdown, chips and actions.
531
+ */
532
+ const MarkdownBubbleSeat = react.memo(function MarkdownBubbleSeat({
533
+ node,
534
+ renderMessageImages,
535
+ openFile,
536
+ openSkill,
537
+ t,
538
+ }) {
539
+ const data = node.data
540
+ const { text, attachments, rest } = contentParts(data.content)
541
+ const referenceLabels = data.referenceLabels ?? []
542
+ const skillNames = data.skillNames ?? []
543
+ const references = react.useMemo(() => ({ openFile, openSkill }), [openFile, openSkill])
544
+ const labels = react.useMemo(() => buildMarkdownLabels(t), [t])
545
+ const compactImages = attachments.length > 1
546
+ const showBubble = text !== '' || rest.length > 0
547
+
548
+ // The action strip leads the row in DOM order and is pushed below the
549
+ // bubble by flex `order`. Sibling plugins pick their bar with
550
+ // `row.querySelector('[class*="_actions"]')` — first match in document
551
+ // order — and a markdown bubble can contain a code card whose own
552
+ // toolbar carries an `_actions` class (`_actions_1pq26_43`). With the
553
+ // strip first, that lookup always lands on the message bar, the way it
554
+ // did before markdown put code cards inside the row.
555
+ return h(
556
+ 'div',
557
+ { className: 'dshmb-row', 'data-dshmb-row': '1' },
558
+ h(MessageActions, { text, time: data.time, t }),
559
+ h(
560
+ 'div',
561
+ { className: 'dshmb-stack' },
562
+ attachments.length > 0 &&
563
+ h(
564
+ 'div',
565
+ { className: 'dshmb-attachments', 'data-message-attachments': true },
566
+ attachments.map((attachment, index) =>
567
+ h(AttachmentEntry, {
568
+ key: `${attachment.type}:${index}`,
569
+ attachment,
570
+ renderMessageImages,
571
+ compactImages,
572
+ }),
573
+ ),
574
+ ),
575
+ showBubble &&
576
+ h(
577
+ 'div',
578
+ { className: 'dshmb-bubble' },
579
+ h(
580
+ MarkdownBoundary,
581
+ { key: text, fallback: plainProjection(text, referenceLabels, skillNames, references) },
582
+ composeUserMarkdown(text, labels, referenceLabels, skillNames, references),
583
+ ),
584
+ rest.map((block, index) =>
585
+ h(primitives.JsonBlock, {
586
+ key: 'rest' + index,
587
+ label: t('message.extraBlock'),
588
+ payload: block,
589
+ truncatedLabel: (total) => t('json.truncated', { total }),
590
+ }),
591
+ ),
592
+ ),
593
+ referenceLabels.length > 0 &&
594
+ h(
595
+ 'div',
596
+ { className: 'dshmb-referenceSummary' },
597
+ t('message.referenceSummary', { labels: referenceLabels.join(t('message.referenceSeparator')) }),
598
+ ),
599
+ ),
600
+ )
601
+ })
602
+
603
+ // --- style ------------------------------------------------------------------
604
+
605
+ // Metrics are copied from the host's own sheets (MessageItem.module.css,
606
+ // MessageIconActions.module.css) so the bubble is the same bubble, but the
607
+ // classes are this plugin's own — no CSS-module hash to chase between
608
+ // builds. Colors stay theme tokens; the two class suffixes sibling plugins
609
+ // search for (`_actions`, `_action`) are preserved on the strip and its
610
+ // buttons on purpose.
611
+ const CSS = [
612
+ '.dshmb-row{flex-direction:column;align-items:flex-end;gap:6px;display:flex}',
613
+ // The strip leads the row's DOM order so sibling plugins' `_actions`
614
+ // lookup finds it before any code-card toolbar inside the bubble;
615
+ // `order` restores the visual order (bubble above, actions below).
616
+ '.dshmb-stack{order:1;min-width:0;max-width:min(calc(var(--dsh-chat-content-width,748px) * .702), 82%);flex-direction:column;align-items:flex-end;gap:8px;display:flex}',
617
+ '.dshmb-bubble{background:var(--dsw-specific-bubble);border-radius:var(--dsw-radius-xl);max-width:100%;font-size:var(--dsh-content-font-size,14px);line-height:calc(22px + var(--dsh-content-font-delta,0px));color:var(--dsw-alias-label-primary);word-break:break-word;padding:10px 16px}',
618
+ '.dshmb-referenceSummary{color:var(--dsw-alias-label-tertiary);font-size:var(--dsh-content-font-size-secondary,13px);line-height:calc(18px + var(--dsh-content-font-delta-secondary,0px))}',
619
+ '.dshmb-attachments{flex-wrap:wrap;justify-content:flex-end;gap:8px;max-width:100%;display:flex}',
620
+ '.dshmb-fileCard{border:.5px solid var(--dsw-alias-border-l2,#0000001f);border-radius:var(--dsw-radius-xl);background:var(--dsw-specific-input-major,transparent);box-sizing:border-box;flex:0 0 240px;align-items:center;gap:10px;width:240px;min-height:64px;padding:8px 12px;display:inline-flex}',
621
+ '.dshmb-fileIcon{flex:none;width:28px;height:28px}',
622
+ '.dshmb-fileContent{flex-direction:column;flex:1;min-width:0;display:flex}',
623
+ '.dshmb-fileName{white-space:nowrap;text-overflow:ellipsis;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:22px;overflow:hidden}',
624
+ '.dshmb-fileMeta{white-space:nowrap;text-overflow:ellipsis;color:var(--dsw-alias-label-tertiary,#00000073);font-size:12px;line-height:15px;overflow:hidden}',
625
+ // The bubble wraps with `pre-wrap`; markdown brings its own line
626
+ // structure, so the markdown container returns to the normal policy.
627
+ '.dshmb-md{white-space:normal;word-break:normal;overflow-wrap:anywhere;min-width:0;max-width:100%}',
628
+ // MarkdownText's document spacing is tuned for a full transcript column;
629
+ // inside a bubble those margins read as gaps. The host sheet's
630
+ // first/last-child collapse is kept.
631
+ '.dshmb-md > div > :first-child{margin-top:0!important}',
632
+ '.dshmb-md > div > :last-child{margin-bottom:0!important}',
633
+ '.dshmb-md > div p{margin:8px 0}',
634
+ '.dshmb-md > div :where(h1,h2,h3){margin:16px 0 8px}',
635
+ '.dshmb-md > div :where(h4,h5,h6){margin:12px 0 6px}',
636
+ '.dshmb-md > div :where(ul,ol){margin:8px 0}',
637
+ '.dshmb-md > div blockquote{margin:8px 0 0}',
638
+ '.dshmb-md > div hr{margin:16px 0}',
639
+ '.dshmb-md > div pre{margin:12px 0}',
640
+ '.dshmb-plain{white-space:pre-wrap;word-break:break-word}',
641
+ // Action strip metrics from the host's MessageIconActions.module.css.
642
+ '.dshmb_actions{order:2;height:calc(28px + var(--dsh-content-font-delta,0px));align-items:center;gap:8px;display:flex}',
643
+ '.dshmb-timeStart{font-size:var(--dsh-content-font-size-secondary,13px);line-height:calc(24px + var(--dsh-content-font-delta,0px));color:var(--dsw-alias-label-tertiary);white-space:nowrap;padding-right:12px}',
644
+ '.dshmb_action{width:calc(28px + var(--dsh-content-font-delta,0px));height:calc(28px + var(--dsh-content-font-delta,0px));border-radius:var(--dsw-radius-sm);color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:none;justify-content:center;align-items:center;padding:6px;display:inline-flex}',
645
+ '.dshmb_action svg{width:calc(15px + var(--dsh-content-font-delta,0px));height:calc(15px + var(--dsh-content-font-delta,0px))}',
646
+ '.dshmb_action:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}',
647
+ '.dshmb_action:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dsw-alias-state-business-primary));outline-offset:2px}',
648
+ // Host parity: every sent message but the latest hides its strip until
649
+ // hover or focus, and the `data-actions-reveal` shell preference does
650
+ // the same globally.
651
+ '@media (hover:hover){',
652
+ '[data-actions-reveal=hover] .dshmb_actions,',
653
+ ':is([data-chat-flow-kind=user],[data-chat-flow-kind=steering]):has(~:is([data-chat-flow-kind=user],[data-chat-flow-kind=steering])) .dshmb_actions{opacity:0;transition:opacity 80ms}',
654
+ '[data-actions-reveal=hover]:hover .dshmb_actions,',
655
+ '[data-actions-reveal=hover]:focus-within .dshmb_actions,',
656
+ ':is([data-chat-flow-kind=user],[data-chat-flow-kind=steering]):has(~:is([data-chat-flow-kind=user],[data-chat-flow-kind=steering])):hover .dshmb_actions,',
657
+ ':is([data-chat-flow-kind=user],[data-chat-flow-kind=steering]):has(~:is([data-chat-flow-kind=user],[data-chat-flow-kind=steering])):focus-within .dshmb_actions{opacity:1}',
658
+ '}',
659
+ ].join('')
660
+
661
+ /**
662
+ * Inject the plugin's stylesheet once. Runs at apply time; the loader's
663
+ * HMR sweep removes it by `data-plugin`, dispose by tag id.
664
+ */
665
+ function injectStyles() {
666
+ if (typeof document === 'undefined') return
667
+ if (document.querySelector('style[data-plugin-css=' + JSON.stringify(CSS_TAG) + ']') !== null) return
668
+ const tag = document.createElement('style')
669
+ tag.dataset.plugin = NS
670
+ tag.dataset.pluginCss = CSS_TAG
671
+ tag.textContent = CSS
672
+ document.head.appendChild(tag)
673
+ }
674
+
675
+ /** Remove the stylesheet this plugin injected, if still present. */
676
+ function removeStyles() {
677
+ if (typeof document === 'undefined') return
678
+ const tag = document.querySelector('style[data-plugin-css=' + JSON.stringify(CSS_TAG) + ']')
679
+ if (tag !== null) tag.remove()
680
+ }
681
+
682
+ // --- plugin -----------------------------------------------------------------
683
+
684
+ /**
685
+ * Browser plugin body: inject the stylesheet and shadow the two sent-text
686
+ * chat seats for as long as this fiber lives. Disposal (or an uninstall)
687
+ * restores the host's own renderers.
688
+ * @param ctx - client cordis context (slots and locale by injection).
689
+ */
690
+ function apply(ctx) {
691
+ injectStyles()
692
+ for (const key of SEATS) {
693
+ ctx.slots.inject('conversation.chat.node', () =>
694
+ ctx.slots.register(
695
+ {
696
+ name: 'conversation.chat.node',
697
+ key,
698
+ priority: SEAT_PRIORITY,
699
+ locale: 'chat',
700
+ },
701
+ MarkdownBubbleSeat,
702
+ ),
703
+ )
704
+ }
705
+
706
+ globalThis[DEBUG_KEY] = { version: PLUGIN_VERSION, seats: SEATS.slice() }
707
+
708
+ ctx.effect(
709
+ () => () => {
710
+ delete globalThis[DEBUG_KEY]
711
+ removeStyles()
712
+ },
713
+ NS + ': lifecycle',
714
+ )
715
+ }
716
+
717
+ exports.apply = apply
718
+ exports.inject = ['slots', 'locale']
719
+ exports.PLUGIN_VERSION = PLUGIN_VERSION
720
+ exports.SEATS = SEATS
721
+ exports.SEAT_PRIORITY = SEAT_PRIORITY
722
+ exports.toMarkdown = toMarkdown
723
+ exports.preserveHardBreaks = preserveHardBreaks
724
+ exports.unwrapSessionReferences = unwrapSessionReferences
725
+ exports.contentParts = contentParts
726
+ exports.composeUserMarkdown = composeUserMarkdown
727
+ exports.MarkdownBubbleSeat = MarkdownBubbleSeat
728
+ return module.exports
729
+ },
730
+ })
package/src/index.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * dsh-markdown-bubble — host half.
3
+ *
4
+ * The plugin has no host-side behavior: a sent message's bubble is drawn by
5
+ * the browser's chat view, and how it renders is a client-side projection of
6
+ * the same text the drawer already receives. The host row exists so the
7
+ * package is mounted by the Loader — the client-module scanner scans Loader
8
+ * entries for packages declaring `dsh.client` — and so the bundle patch has a
9
+ * row id to insert.
10
+ *
11
+ * @module dsh-markdown-bubble
12
+ */
13
+
14
+ /** Stable plugin id (matches the bundle patch's insert id and the client module id). */
15
+ export const PLUGIN_ID = 'dsh-markdown-bubble'
16
+
17
+ /** Keep in sync with package.json and src/client.js. */
18
+ export const PLUGIN_VERSION = '0.1.1'
19
+
20
+ /** Cordis plugin name; the bundle patch resolves the package by this row. */
21
+ export const name = PLUGIN_ID
22
+
23
+ /** Services the inert entry needs: none. */
24
+ export const inject = []
25
+
26
+ /**
27
+ * Inert apply: the browser half owns every behavior.
28
+ * @param {import('@deepseek-ai/cordis').Context} _ctx - owning context.
29
+ */
30
+ export function apply(_ctx) {}