@restregular/dsh-notepad 0.6.0 → 0.7.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/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # dsh-notepad
2
2
 
3
- 在 DeepSeek Harness 的会话界面里放一张**属于当前工作区**的分段便签浮窗:一行一个问题、一段一个卡片,回车就能新建下一段;需要时把某一段、或勾选后的多段一起插入 / 替换 / 发送到对话框。除了文字,也可以直接**粘贴或选择图片**,输入框里会用缩略芯片显示并可点击预览。0.4 起新分段置顶、点击序号选中、拖拽排序、合并多段;0.5 起空标题只在点击后显示输入框,方向键可切换分段,AI 优化只处理选中的分段并支持撤销 / 缓存;0.6 起插入或发送按钮 hover 时展开成「当前会话 / 新会话」按钮组,图片可在浮窗内预览,可直接删除分段(二次确认、不可恢复),底部快捷键提示点击轮换,复制全部不再包含归档内容,发送行为跟随「设置 > 通用设置」的繁忙发送策略。
3
+ 在 DeepSeek Harness 的会话界面里放一张**属于当前工作区**的分段便签浮窗:一行一个问题、一段一个卡片,回车就能拆出下一段;需要时把某一段、或勾选后的多段一起插入 / 替换 / 发送到对话框。除了文字,也可以直接**粘贴或选择图片**,输入框里会用缩略芯片显示并可点击预览。0.4 起支持选中、拖拽排序、合并多段;0.5 起空标题只在点击后显示输入框,方向键可切换分段,AI 优化只处理选中的分段并支持撤销 / 缓存;0.6 起插入 / 发送按钮 hover 时展开成「当前会话 / 新会话」按钮组,图片可在浮窗内预览,底部快捷键提示点击轮换,复制全部排除归档,发送行为跟随「设置 > 通用设置」的繁忙发送策略;0.7 起分段改为从上到下、旧→新排列,回车拆分的新卡片紧跟原卡片,段首 Backspace 与上一段合并,空分段按 Backspace 或点删除会直接移除,插入 / 发送 / 归档+删除按钮组会稍候再展开以避免快速划过闪烁,文本达到最大高度才出现滚动条,复位快捷键改为 `Ctrl/Cmd + Shift + R`。
4
4
 
5
- > A per-workspace floating segmented notepad for DeepSeek Harness sessions. New segment cards lead the list; an empty title is a blank click target, arrow keys move between cards, and AI improvement only touches the selected cards — the button toggles to undo and unchanged content reuses the cached answer. In 0.6 the insert and send buttons expand on hover into current-session / new-session groups, image chips open an in-panel preview, active segments can be deleted after a confirmation that warns the action cannot be undone, the footer rotates shortcut tips, copy-all skips archived content, and sending follows the General Settings busy-send policy. Auto refresh preserves the list scroll position, `Ctrl/Cmd + Shift + 0` (or the header reset button) resets the geometry, the panel never covers the host title bar, and notes live in `<workspace>/.dsh/notepad.json` shared by every session from that workspace.
5
+ > A per-workspace floating segmented notepad for DeepSeek Harness sessions. Cards run oldest-to-newest; Enter splits after the source card and Backspace at the start merges into the previous card, while empty cards are removed by Backspace or the delete action without a prompt. An empty title is a blank click target, arrow keys move between cards, and AI improvement only touches the selected cards — the button toggles to undo and unchanged content reuses the cached answer. Hover groups wait briefly before opening: insert/send include current-session and new-session actions, archive+delete shares one group, and replace stays single. Image chips open an in-panel preview, the footer rotates shortcut tips, copy-all skips archived content, sending follows the General Settings busy-send policy, and the textarea only scrolls at its height limit. Auto refresh preserves the list scroll position, `Ctrl/Cmd + Shift + R` (or the header reset button) resets the geometry, the panel never covers the host title bar, and notes live in `<workspace>/.dsh/notepad.json` shared by every session from that workspace.
6
6
 
7
7
  ## 安装
8
8
 
@@ -20,39 +20,46 @@ dsh plugin --profile web add @restregular/dsh-notepad
20
20
  │ ☐ 全选 勾选分段可批量操作 ↙ ⇄ ➤ ⊕ 合并 ✦ 归档 │
21
21
  │ ┌────────────────────────────────────────┐ │
22
22
  │ │ ☐ 01 [标题] 🖼 ↙ ⇄ ➤ ✦ 归档 │
23
- │ │ 记录最新的一段…… │ │
23
+ │ │ 记录较早的一段…… │ │
24
24
  │ └────────────────────────────────────────┘ │
25
25
  │ ┌────────────────────────────────────────┐ │
26
26
  │ │ ☐ 02 [标题] 🖼 ↙ ⇄ ➤ ✦ 归档 │
27
- │ │ 记录上一段…… │ │
27
+ │ │ 记录最新的一段…… │ │
28
28
  │ └────────────────────────────────────────┘ │
29
29
  │ 已保存 · 42 字符 · 2 段 Enter 新建分段… │
30
30
  └────────────────────────────────────────────┘
31
31
  ```
32
32
 
33
- - **新建分段置顶**:标题栏 `+` 或分段末尾回车会在列表顶部创建新卡片;卡片从上到下保持最近在前,拖拽后按你的顺序保存。
33
+ - **排列方向**:卡片从上到下按旧分段到新分段排列;`+` 新建的分段追加在列表底部,拖拽后按你的顺序保存。
34
34
  - **标题**:空标题不显示任何占位文字,只保留一块可点击的空白;点击后才出现输入框。已有标题的卡片直接显示输入框,边输入边自动保存;在输入框里清空后不会立刻消失,失焦才回到空白状态。
35
35
  - **序号**:显示当前顺序(`01`、`02`…),点击可选中 / 取消选中这一段,拖动序号可调整卡片顺序。
36
36
  - **AI 优化**:勾选分段后,顶部工具栏的 ✦ 只优化选中的分段;卡片工具栏的 ✦ 优化此段。优化成功后,刚点过的按钮变成「撤销优化」,再点一次恢复原文;相同且未改动的标题 + 内容再次优化时直接复用上次结果,不再调用大模型。
37
37
  - **方向键**:光标在分段首行按 `↑` 跳到上一张卡片末尾;在末行按 `↓` 跳到下一张卡片开头。多行分段内部仍按正常文本光标移动。
38
- - **插入 / 发送按钮组**:鼠标 hover 插入或发送按钮时,它会横向展开成两个按钮,并用细圆角边框框成一组:第一个作用于当前会话,第二个在新会话中插入 / 发送。新会话会创建在同一工作区;替换按钮没有按钮组。
38
+ - **按钮组**:插入、发送、归档+删除按钮在鼠标 hover 约 0.2 秒后才横向展开成按钮组,并用细圆角边框框起,快速划过不会闪烁:
39
+ - 插入 / 发送组:当前会话 + 在同一工作区新会话中执行;
40
+ - 归档+删除组:归档(主按钮)+ 直接删除(悬停后出现的危险按钮);
41
+ - 替换按钮保持单键。
42
+ - **回车拆分**:在分段中按回车会从光标处拆开,新卡片**紧跟原卡片下方**;在末尾回车就是在该段后面新建一个空分段。
43
+ - **Backspace 合并 / 删除**:光标在段首按 `Backspace` 会与上一段合并;如果当前段完全为空(无标题、无文字、无图片),则直接删除该段,不弹确认框。删除按钮对空分段同样跳过确认。
39
44
  - **图片预览**:点击任意图片芯片会在浮窗内打开预览(带「打开原图」和关闭按钮),点击遮罩或按 `Esc` 关闭。
40
- - **删除分段**:未归档卡片上新增垃圾桶按钮,点击后弹窗二次确认,并明确提示删除后无法恢复;确认后直接删除,不进入归档,也不提供撤销。
45
+ - **删除分段**:非空分段的删除按钮位于归档+删除按钮组的展开项中,点击后弹窗二次确认,并明确提示删除后无法恢复;确认后直接删除,不进入归档,也不提供撤销。
41
46
  - **复制全部**:只复制未归档分段,归档内容不会混入结果。
42
47
  - **快捷键提示**:底部一次只显示一条快捷键提示,点击它会轮换显示下一条。
43
48
  - **发送行为**:发送按钮在智能体繁忙时使用「设置 > 通用设置 > 繁忙时的发送行为」中选择的排队 / 插话策略;空闲时按普通发送处理。
44
- - **复位浮窗**:`Ctrl / Cmd + Shift + 0` 或标题栏的复位按钮把位置和尺寸恢复为右下角的默认 `420 × 460`,最小化 / 归档展开等状态不变。
49
+ - **复位浮窗**:`Ctrl / Cmd + Shift + R` 或标题栏的复位按钮把位置和尺寸恢复为右下角的默认 `420 × 460`,最小化 / 归档展开等状态不变。
50
+ - **高度上限**:分段文本区最大 240px,只有内容确实超过该高度时才出现纵向滚动条;未到上限前不会预留滚动条。
45
51
  - **合并**:勾选 2 段以上后点顶部工具栏的合并按钮,合并结果留在最靠上的选中位置。
46
- - **回车 / Enter**:把当前分段从光标处拆开,创建一张新的分段卡片(光标前的文字留在原卡片,光标后的文字进入新卡片)。在末尾回车就是新建一个空分段并置顶。
47
- - **Shift + Enter**:分段内换行。
48
- - **Ctrl / Cmd + Enter**:把当前分段(有勾选时改为勾选的多段)插入对话框。
52
+ - **Enter**:拆分当前分段(见上);**Shift + Enter** 是分段内换行;**Ctrl / Cmd + Enter** 把当前分段(有勾选时改为勾选的多段)插入对话框。
49
53
  - 卡片上的 **↙** 插入此段;**⇄** 用此段替换整个对话框内容;**➤** 直接发送此段。
50
54
 
51
55
  ## 功能
52
56
 
53
57
  | 操作 | 入口 | 说明 |
54
58
  | --- | --- | --- |
55
- | 新建分段 | 标题栏 `+`,或分段末尾按回车 | 新卡片置顶并自动获得焦点 |
59
+ | 新建分段 | 标题栏 `+`,或分段末尾按回车 | 新卡片追加在列表底部(旧→新),并自动获得焦点 |
60
+ | 回车拆分 | 分段文本内按 `Enter` | 从光标处拆开,新卡片紧跟当前卡片下方 |
61
+ | Backspace 合并 | 光标在分段最前面按 `Backspace` | 与上一张卡片合并,光标停在新旧文字交界处 |
62
+ | 空分段删除 | 空分段内按 `Backspace`,或空分段的删除按钮 | 无标题、无文字、无图片时直接删除,不弹确认 |
56
63
  | 编辑分段 | 点击卡片文本区 | 停止输入约 0.5 秒后自动保存 |
57
64
  | 分段标题 | 空标题的空白点击区 / 已有标题的输入框 | 空标题不显示文字和 hover 效果,点击后才出现输入框;停止输入约 0.5 秒后自动保存 |
58
65
  | 选中分段 | 点击左上角序号,或左侧复选框 | 序号与复选框状态同步;点击序号即可参与批量操作 |
@@ -70,23 +77,24 @@ dsh plugin --profile web add @restregular/dsh-notepad
70
77
  | 带图插入 / 发送 | 与文字相同入口 | 图片以 `![名称](路径)` 追加在分段文字之后 |
71
78
  | 单段插入 | 卡片上的左下箭头 | 插入到当前会话输入框光标处,不发送 |
72
79
  | 多段插入 | 勾选分段 → 工具栏左下箭头,或全选后操作 | 按卡片顺序用空行合并后一次插入 |
73
- | 在新会话中插入 | hover 插入按钮后出现的第二个按钮 | 在同一工作区新建会话并打开,内容填进它的输入框 |
80
+ | 在新会话中插入 | hover 插入按钮片刻后出现的第二个按钮 | 在同一工作区新建会话并打开,内容填进它的输入框 |
74
81
  | 替换对话框 | 卡片上的 ⇄,或勾选后工具栏 ⇄ | 用该段 / 多段合并内容替换整个输入框(没有按钮组) |
75
82
  | 单段发送 | 卡片上的纸飞机 | 直接发进当前会话;发送成功后自动归档该段 |
76
83
  | 多段发送 | 勾选分段 → 工具栏纸飞机 | 合并成一条消息发送;发送成功后自动归档这些段 |
77
- | 在新会话中发送 | hover 发送按钮后出现的第二个按钮 | 在同一工作区新建会话并发送,随后切换到该会话 |
84
+ | 在新会话中发送 | hover 发送按钮片刻后出现的第二个按钮 | 在同一工作区新建会话并发送,随后切换到该会话 |
78
85
  | 全选 | 列表上方「全选」复选框 | 一键选中所有未归档分段,再点一次取消 |
79
- | 归档 | 卡片上的归档图标,或工具栏归档图标 | 把分段移入归档区(不发送) |
86
+ | 归档 | 卡片上归档+删除按钮组的主按钮,或工具栏归档图标 | 把分段移入归档区(不发送) |
80
87
  | 显示 / 隐藏归档 | 标题栏归档图标 | 展开 / 收起归档区,状态按浏览器记忆 |
81
88
  | 恢复 | 归档卡片上的恢复图标 | 把归档分段放回未归档列表 |
82
- | 删除(未归档) | 未归档卡片上的垃圾桶 | 跳过归档直接删除;弹窗二次确认并提示 **删除后无法恢复** |
89
+ | 删除(未归档) | 归档+删除按钮组中 hover 出现的垃圾桶 | 空分段直接删除;非空分段弹窗二次确认并提示 **删除后无法恢复** |
83
90
  | 删除(已归档) | 归档卡片上的垃圾桶 | 删除归档分段,可撤销 |
84
91
  | 清空归档 | 归档区标题「清空归档」 | 批量删除归档,可撤销 |
85
92
  | 复制全部 | 标题栏复制按钮 | 只复制未归档分段,用空行合并;归档内容不包含在内 |
86
93
  | 快捷键提示 | 底部的一条提示文字 | 点击轮换显示下一条快捷键说明 |
87
94
  | 发送策略 | 卡片 / 工具栏发送按钮 | 繁忙时按「设置 > 通用设置 > 繁忙时的发送行为」选择排队或插话 |
95
+ | 滚动条 | 分段文本达到 240px 高度上限后 | 未到上限不显示纵向滚动条,超过后才出现 |
88
96
  | 调整尺寸 | 拖动右下角斜纹手柄 | 宽高按浏览器记忆,最小 320 × 240 |
89
- | 复位位置和尺寸 | `Ctrl/Cmd + Shift + 0`,或标题栏复位按钮 | 恢复默认 420 × 460 并停到右下角,其它状态不变 |
97
+ | 复位位置和尺寸 | `Ctrl/Cmd + Shift + R`,或标题栏复位按钮 | 恢复默认 420 × 460 并停到右下角,其它状态不变 |
90
98
  | 折叠 / 展开 | 标题栏上下折叠箭头 | 只保留标题栏,内容继续自动保存 |
91
99
  | 最小化 / 恢复 | 标题栏最小化图标,或 `Ctrl/Cmd + Shift + N` | 收成右下角小图标,带平滑过渡 |
92
100
  | 拖动位置 | 按住标题栏拖动 | 位置按浏览器记忆;不会越过 DSH 窗口最顶部的标题栏(最大化 / 最小化 / 关闭那一栏) |
@@ -105,8 +113,14 @@ dsh plugin --profile web add @restregular/dsh-notepad
105
113
 
106
114
  ```json
107
115
  {
108
- "version": 4,
116
+ "version": 5,
109
117
  "segments": [
118
+ {
119
+ "id": "seg-1",
120
+ "text": "遇到的问题\n下一步:补测试",
121
+ "title": "待办",
122
+ "createdAt": "2026-10-23T03:19:57.000Z"
123
+ },
110
124
  {
111
125
  "id": "seg-2",
112
126
  "text": "看这张报错截图",
@@ -114,23 +128,17 @@ dsh plugin --profile web add @restregular/dsh-notepad
114
128
  "createdAt": "2026-10-23T03:19:58.000Z",
115
129
  "images": [{ "path": ".dsh/notepad/images/m1x2-image.png", "name": "image.png" }]
116
130
  },
117
- {
118
- "id": "seg-1",
119
- "text": "遇到的问题\n下一步:补测试",
120
- "title": "待办",
121
- "createdAt": "2026-10-23T03:19:57.000Z"
122
- },
123
131
  { "id": "seg-3", "text": "已发送的分段", "archived": true, "archivedAt": "2026-10-23T03:20:00.000Z" }
124
132
  ],
125
- "text": "看这张报错截图\n![image.png](.dsh/notepad/images/m1x2-image.png)\n\n遇到的问题\n下一步:补测试\n\n已发送的分段",
133
+ "text": "遇到的问题\n下一步:补测试\n\n看这张报错截图\n![image.png](.dsh/notepad/images/m1x2-image.png)\n\n已发送的分段",
126
134
  "updatedAt": "2026-10-23T03:20:01.000Z"
127
135
  }
128
136
  ```
129
137
 
130
- `text` 是 `segments`(含图片引用)用空行合并后的副本,方便人工查看,也兼容旧版本。旧版单段文件(只有 `text`)、v2/v3 分段文件和手动改成纯文本的文件会在读取时迁移;没有 `createdAt` 的旧笔记会按「最近在前」反转一次显示顺序,之后按卡片数组顺序保存(拖拽后的顺序不会被自动重排)。`images[].path` 工作区内用相对路径,工作区外保留绝对路径;`name` 仅用于显示和 Markdown 替代文本。`title` 最长 200 字符。
138
+ `text` 是 `segments`(含图片引用)用空行合并后的副本,方便人工查看,也兼容旧版本。v5 起卡片按从旧到新、从上到下的顺序保存;读取到 v4(0.4–0.6)文件时,客户端会**反转一次**以迁移顺序,之后按卡片数组顺序保存(拖拽后的顺序不会被自动重排)。旧版单段文件(只有 `text`)、v2/v3 分段文件和手动改成纯文本的文件会在读取时迁移为分段;`images[].path` 工作区内用相对路径,工作区外保留绝对路径;`name` 仅用于显示和 Markdown 替代文本。`title` 最长 200 字符。
131
139
  - **粘贴的图片**保存在 `<工作区>/.dsh/notepad/images/`,由宿主接口原子写入,只接受 PNG / JPEG / WebP / GIF,单张最大 20 MB;笔记只保存路径,不内嵌 base64。**选择的图片**直接引用原路径,不复制。每段最多 20 张。
132
140
  - **删除图片芯片**会同时删除插件自己保存在 `.dsh/notepad/images/` 下的对应文件;引用工作区外原路径的图片只删引用,不碰原文件。删除已归档分段 / 清空归档 / 直接删除未归档分段时,未被其它分段引用的插件图片会在撤销窗口(未归档删除没有撤销窗口,按同样延迟)结束后自动清理。
133
- - 浮窗位置、尺寸、折叠 / 最小化 / 隐藏状态、归档区是否展开保存在浏览器 `localStorage` 的 `dsh-notepad.ui.v1` 中,按浏览器 / 设备生效,不写入工作区。位置会被限制在宿主标题栏下方(读取 DSH 的 `--dsh-frame-chrome-top` / `--dsh-frame-top-clearance`),`Ctrl/Cmd + Shift + 0` 或标题栏复位按钮可随时复位位置和尺寸。
141
+ - 浮窗位置、尺寸、折叠 / 最小化 / 隐藏状态、归档区是否展开保存在浏览器 `localStorage` 的 `dsh-notepad.ui.v1` 中,按浏览器 / 设备生效,不写入工作区。位置会被限制在宿主标题栏下方(读取 DSH 的 `--dsh-frame-chrome-top` / `--dsh-frame-top-clearance`),`Ctrl/Cmd + Shift + R` 或标题栏复位按钮可随时复位位置和尺寸。
134
142
  - 每个工作区一份笔记,最多 **200 段 / 512 KB**(单段输入框限制 16 万字符,每段最多 20 张图片)。浮窗内是纯文本编辑,不做 Markdown 渲染;图片以缩略芯片展示。
135
143
  - 浮窗只在**会话视图**出现:打开设置等非会话页面时自动隐藏,回到会话后恢复。
136
144
  - 写入走宿主同源回环路由 `/dsh-notepad/api/note`,粘贴图片走 `/dsh-notepad/api/image`,AI 优化走 `/dsh-notepad/api/optimize`,侧边栏角标计数走 `/dsh-notepad/api/unsent`;这些都只接受 127.0.0.1 的同源请求,并且只允许当前会话的 cwd 或已注册工作区。图片接口只会在固定的 `.dsh/notepad/images/` 下写入 / 删除合法的图片文件,不能用来读写或删除工作区里的其它文件。AI 优化使用当前默认模型,未配置模型时返回 `unavailable`,不会影响记事本的其它功能。
@@ -140,9 +148,9 @@ dsh plugin --profile web add @restregular/dsh-notepad
140
148
  ## 实现
141
149
 
142
150
  - **Host**(`index.js` + `lib/note-store.js` + `lib/image-store.js`):`ctx.inject(['webServer'])` 注册 `/dsh-notepad/api` 下的 `GET/PUT /note`、`POST/DELETE /image`、`GET /unsent` 与 `POST /optimize`,用临时文件 + rename 原子写入 `.dsh/notepad.json`;`GET` 返回 `segments`(含 `title` / `createdAt` / `images`)与合并后的 `text`,`PUT` 接受 `{ segments: [...] }`,也兼容旧的 `{ text }`。`POST /image` 校验 base64、MIME 与文件签名后写入 `.dsh/notepad/images/`;`DELETE /image` 只允许删除该目录下的普通文件。`/unsent` 为已注册工作区统计未发送段数;`/optimize` 先用 `workspace + title + text` 查进程内缓存,命中时返回 `{ cached: true }`,否则通过 `agentDefaultModel.currentSelection()` + `llm.prepareCall()` 调用当前默认模型,流式拼接文本后写入缓存并返回。
143
- - **Client**(`client.js`):整个 bundle 包在 IIFE 里;通过 `shell.overlay` 注册浮窗,读取 `uiSession.adapter.current` 得到当前会话与工作区。标题、序号、拖拽排序和合并都在本地分段数组上完成,数组顺序就是保存顺序;空标题用无内容的点击区渲染,点击后才挂载输入框,`↑` / `↓` 在首 / 末行时把焦点和光标移到相邻卡片。插入 / 发送按钮 hover 时用 CSS 横向展开成当前会话 / 新会话两个按钮,并用细圆角边框包住;新会话通过 `uiWorkspace.startSession()`(插入)或 `sessions.create()` + 会话发送(发送)完成。图片芯片打开面板内的预览遮罩,支持打开原图与 `Esc` 关闭;未归档删除走自定义二次确认弹窗,确认后立即从分段数组移除并清理未被引用的图片。复制全部只合并未归档分段;底部快捷键提示是一个按钮,每次只渲染一条并通过 `HINT_KEYS` 轮换。侧边栏角标用 `MutationObserver` 监听 `[data-row-key^="workspace:"]` 并把未发送数量写成 `data-np-unsent` 属性,由 CSS 伪元素显示。粘贴图片用 `FileReader` 读成 base64 交给宿主保存;选择 / 拖入图片优先通过桌面桥 `__DSH_HOST_PATHS__.pathFor(file)` 拿宿主路径。插入 / 替换通过会话绑定里的 `inputActions.captureInsertion()/insertText()` 与 `setDraft()`;发送优先按繁忙策略选择 `sessions.scope(id).conversation.send()`(排队)或 `sessions.binding(id).session.prompt(..., 'steer')`(插话),steer 不可用时回退到排队。图片在插入 / 发送时以 Markdown 图片语法追加,聊天记录里的 DSH 渲染器会按工作区 cwd 解析这些路径。
151
+ - **Client**(`client.js`):整个 bundle 包在 IIFE 里;通过 `shell.overlay` 注册浮窗,读取 `uiSession.adapter.current` 得到当前会话与工作区。卡片按从旧到新排列,新建/拆分都追加在原卡片之后;光标在段首按 `Backspace` 时,空分段直接删除,非空分段与上一段合并(合并标题、文字与图片,并保持光标在交界处)。标题、序号、拖拽排序和合并都在本地分段数组上完成,数组顺序就是保存顺序;空标题用无内容的点击区渲染,点击后才挂载输入框,`↑` / `↓` 在首 / 末行时把焦点和光标移到相邻卡片。插入、发送、归档+删除使用同一个 `actionGroup()` 组件;按钮组用纯 CSS 延迟约 0.2 秒展开(收起也有短暂延迟),避免鼠标快速划过时闪烁。插入 / 发送组的新会话通过 `uiWorkspace.startSession()`(插入)或 `sessions.create()` + 会话发送(发送)完成;归档+删除组主按钮归档,悬停出现的危险按钮删除。文本区由 `autoGrow()` 在 26–240px 之间自适应,只有 `scrollHeight > 240` 时才把 `overflow-y` 切到 `auto`。图片芯片打开面板内的预览遮罩,支持打开原图与 `Esc` 关闭;非空删除走自定义二次确认,空分段跳过确认,确认后立即从分段数组移除并清理未被引用的图片。复制全部只合并未归档分段;底部快捷键提示是一个按钮,每次只渲染一条并通过 `HINT_KEYS` 轮换。侧边栏角标用 `MutationObserver` 监听 `[data-row-key^="workspace:"]` 并把未发送数量写成 `data-np-unsent` 属性,由 CSS 伪元素显示。粘贴图片用 `FileReader` 读成 base64 交给宿主保存;选择 / 拖入图片优先通过桌面桥 `__DSH_HOST_PATHS__.pathFor(file)` 拿宿主路径。插入 / 替换通过会话绑定里的 `inputActions.captureInsertion()/insertText()` 与 `setDraft()`;发送优先按繁忙策略选择 `sessions.scope(id).conversation.send()`(排队)或 `sessions.binding(id).session.prompt(..., 'steer')`(插话),steer 不可用时回退到排队。图片在插入 / 发送时以 Markdown 图片语法追加,聊天记录里的 DSH 渲染器会按工作区 cwd 解析这些路径。
144
152
  - AI 优化只改分段文字,不动标题和图片;单段优化后该卡片按钮变为「撤销优化」,批量优化后工具栏按钮变为「撤销优化」,撤销会恢复优化前的文字。优化结果按内容缓存在客户端与宿主两侧,相同内容再次优化不会重复请求;没有默认模型或模型调用失败时只提示错误,不影响笔记读写。
145
- - 浮窗坐标同时受视口和宿主标题栏约束:拖动、调整尺寸、窗口缩放、标题栏复位按钮和快捷键复位都走同一个 `clampUi()`,最小 `top` 取 `--dsh-frame-chrome-top`(全屏时为 0)/ `--dsh-frame-top-clearance` 再加 8px。复位快捷键优先注册到 DSH 的 `shortcuts` 服务(默认 `Ctrl/Cmd + Shift + 0`、`regions` 含 `editable`),注册失败时保留原始 keydown 监听,标题栏也有可点击的复位按钮。
153
+ - 浮窗坐标同时受视口和宿主标题栏约束:拖动、调整尺寸、窗口缩放、标题栏复位按钮和快捷键复位都走同一个 `clampUi()`,最小 `top` 取 `--dsh-frame-chrome-top`(全屏时为 0)/ `--dsh-frame-top-clearance` 再加 8px。复位快捷键优先注册到 DSH 的 `shortcuts` 服务(默认 `Ctrl/Cmd + Shift + R`、`regions` 含 `editable`),注册失败时保留原始 keydown 监听,标题栏也有可点击的复位按钮。
146
154
  - 发送模式与 DSH 输入框一致:读取 `sessions.list` 的 `running`,繁忙时读取 `configForms.get('ui-conversation')` 快照里的 `busyEnter`(`queue` / `steer`);空闲时始终排队。
147
155
  - 文案通过宿主 `locale` 服务注册中英文;找不到 locale 时按浏览器语言回退。
148
156
 
@@ -167,16 +175,18 @@ node test/package.test.mjs
167
175
  npm test
168
176
  ```
169
177
 
170
- `test/client-smoke.mjs` 会真正执行 `apply(ctx)` 并渲染浮窗,覆盖:分段加载、编辑自动保存、自动刷新保持列表滚动位置、新分段置顶、空标题空白点击区与点击后出现输入框、标题自动保存、序号选中、方向键跨分段切换光标、合并、单段 / 选中分段 AI 优化、优化后按钮切换为撤销、相同内容命中缓存、插入 / 发送 hover 按钮组与在新会话中插入 / 发送、图片面板内预览、未归档删除的二次确认与不可撤销、底部快捷键轮换、复制全部排除归档、繁忙发送策略、拖拽排序、未发送角标、侧边栏工作区角标、回车拆分、单段/多段插入与替换、单段/多段发送与自动归档、归档显示隐藏、归档恢复与删除(含撤销)、全选、折叠、最小化 / 恢复、拖动调整尺寸、标题栏下方位置限制、全局快捷键与复位快捷键、DSH `shortcuts` 命令注册、深色模式与字体 CSS、重复执行 bundle 的兼容性,以及粘贴 / 选择图片、图片芯片、删除芯片时清理磁盘文件与带图插入发送。
178
+ `test/client-smoke.mjs` 会真正执行 `apply(ctx)` 并渲染浮窗,覆盖:分段加载、编辑自动保存、自动刷新保持列表滚动位置、新分段追加到底部、空标题空白点击区与点击后出现输入框、标题自动保存、序号选中、方向键跨分段切换光标、段首 Backspace 合并上一段、空分段 Backspace / 删除按钮无确认删除、回车拆分后新卡片紧跟原卡片、文本区未到高度上限不显示滚动条、合并、单段 / 选中分段 AI 优化、优化后按钮切换为撤销、相同内容命中缓存、插入 / 发送 / 归档+删除 hover 按钮组(含延迟 CSS)与在新会话中插入 / 发送、图片面板内预览、非空删除的二次确认与不可撤销、底部快捷键轮换、复制全部排除归档、繁忙发送策略、拖拽排序、未发送角标、侧边栏工作区角标、单段/多段插入与替换、单段/多段发送与自动归档、归档显示隐藏、归档恢复与删除(含撤销)、全选、折叠、最小化 / 恢复、拖动调整尺寸、标题栏下方位置限制、全局快捷键与 `Ctrl+Shift+R` 复位、DSH `shortcuts` 命令注册(`KeyR`)、深色模式与字体 CSS、重复执行 bundle 的兼容性,以及粘贴 / 选择图片、图片芯片、删除芯片时清理磁盘文件与带图插入发送。
171
179
 
172
180
  ## 已知限制
173
181
 
174
182
  - 浮窗不会自动把内容推给 Agent;发送使用当前会话的发送接口,繁忙时按「设置 > 通用设置」的繁忙发送策略排队或插话。
175
183
  - 发送按钮依赖 DSH 的会话接口;接口不可用时降级为插入输入框并提示。
176
184
  - 「在新会话中插入 / 发送」依赖 DSH 的 `uiWorkspace` / `sessions` 服务;不可用时插入会降级为复制,发送会提示失败。
185
+ - `Ctrl/Cmd + Shift + R` 在部分浏览器里是硬刷新快捷键;DSH 桌面端通过 `shortcuts` 先消费该组合,网页端如果仍被浏览器占用,可用标题栏的复位按钮。
177
186
  - 图片只支持 PNG / JPEG / WebP / GIF,单张最大 20 MB、每段最多 20 张;其它附件类型暂不支持。
178
187
  - AI 优化需要 DSH 里已配置默认模型;未配置时按钮会提示不可用,记笔记本身不受影响。优化缓存保存在宿主进程内,重启 DSH 或切换默认模型后,相同内容会再请求一次。
179
- - 删除未归档分段不可撤销;确认后不会进入归档,只会在延迟窗口后清理未被其它分段引用的插件图片。
188
+ - 删除非空未归档分段不可撤销;确认后不会进入归档,只会在延迟窗口后清理未被其它分段引用的插件图片。空分段直接删除。
189
+ - 从 v4 笔记迁移到 v5 时会整体反转一次卡片顺序;如果你之前在 v4 里手动拖过顺序,这次反转会把它倒过来,之后按新顺序保存。
180
190
  - 删除图片芯片会删除插件目录里的文件;如果同一路径仍被其它分段引用,则保留文件。选择的原路径图片只删引用,不删除原图。
181
191
  - 网页版没有 `__DSH_HOST_PATHS__` 桥,选择 / 拖入的图片会像粘贴一样先复制到 `.dsh/notepad/images/`。
182
192
  - 图片在浮窗里用缩略芯片表示,不提供富文本行内图文混排;插入 / 发送后由聊天记录按 Markdown 渲染。
package/client.js CHANGED
@@ -50,6 +50,7 @@ window.__ModuleLoader__.load({
50
50
  const SAVE_DELAY = 500;
51
51
  const MAX_SEGMENTS = 200;
52
52
  const MAX_SEGMENT_CHARS = 160000;
53
+ const MAX_SEGMENT_HEIGHT = 240;
53
54
  const MAX_TITLE_CHARS = 200;
54
55
  const MAX_IMAGES = 20;
55
56
  const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
@@ -179,7 +180,7 @@ window.__ModuleLoader__.load({
179
180
  hintNewline: 'Shift+Enter 分段内换行',
180
181
  hintInsert: 'Ctrl+Enter 插入当前分段',
181
182
  hintToggle: 'Ctrl+Shift+N 显示 / 隐藏浮窗',
182
- hintReset: 'Ctrl+Shift+0 复位浮窗',
183
+ hintReset: 'Ctrl+Shift+R 复位浮窗',
183
184
  hintArrow: '↑ / ↓ 切换分段',
184
185
  hintPaste: '粘贴图片自动保存到工作区',
185
186
  resetShortcut: '复位记事本位置和尺寸',
@@ -312,7 +313,7 @@ window.__ModuleLoader__.load({
312
313
  hintNewline: 'Shift+Enter adds a line break',
313
314
  hintInsert: 'Ctrl+Enter inserts the current segment',
314
315
  hintToggle: 'Ctrl+Shift+N shows / hides the notepad',
315
- hintReset: 'Ctrl+Shift+0 resets the floating window',
316
+ hintReset: 'Ctrl+Shift+R resets the floating window',
316
317
  hintArrow: '↑ / ↓ switch segments',
317
318
  hintPaste: 'Pasted images are saved in the workspace',
318
319
  resetShortcut: 'Reset the notepad position and size',
@@ -784,12 +785,14 @@ window.__ModuleLoader__.load({
784
785
  .dsh-np-segbtn[data-ai=true]{color:var(--np-accent);opacity:1}
785
786
  .dsh-np-segbtn[data-danger=true]:hover:not(:disabled){color:var(--dsw-alias-state-error-primary,#e25555);background:var(--dsw-alias-interactive-bg-hover-danger,rgb(236 19 19/.05))}
786
787
  .dsh-np-segbtn:disabled{opacity:.3;cursor:default}
787
- .dsh-np-actiongroup{display:inline-flex;align-items:center;flex:none;padding:1px;border:1px solid transparent;border-radius:8px;transition:border-color .12s,background .12s}
788
- .dsh-np-actiongroup:hover,.dsh-np-actiongroup:focus-within{border-color:var(--dsw-alias-border-l2,rgb(15 23 42/.14));background:color-mix(in srgb,var(--np-surface-2) 58%,transparent)}
789
- .dsh-np-group-extra{width:0!important;min-width:0;padding:0;border:0;opacity:0;visibility:hidden;pointer-events:none;overflow:hidden;transition:width .14s ease,opacity .12s ease,visibility 0s linear .14s}
790
- .dsh-np-actiongroup:hover .dsh-np-group-extra,.dsh-np-actiongroup:focus-within .dsh-np-group-extra{width:24px!important;opacity:1;visibility:visible;pointer-events:auto;transition:width .14s ease,opacity .12s ease,visibility 0s linear 0s}
788
+ .dsh-np-actiongroup{display:inline-flex;align-items:center;flex:none;padding:1px;border:1px solid transparent;border-radius:8px;transition:border-color .12s ease .12s,background .12s ease .12s}
789
+ .dsh-np-actiongroup:hover{border-color:var(--dsw-alias-border-l2,rgb(15 23 42/.14));background:color-mix(in srgb,var(--np-surface-2) 58%,transparent);transition-delay:.18s}
790
+ .dsh-np-actiongroup:focus-within{border-color:var(--dsw-alias-border-l2,rgb(15 23 42/.14));background:color-mix(in srgb,var(--np-surface-2) 58%,transparent);transition-delay:0s}
791
+ .dsh-np-group-extra{width:0!important;min-width:0;padding:0;border:0;opacity:0;visibility:hidden;pointer-events:none;overflow:hidden;transition:width .14s ease .12s,opacity .12s ease .12s,visibility 0s linear .26s}
792
+ .dsh-np-actiongroup:hover .dsh-np-group-extra{width:24px!important;opacity:1;visibility:visible;pointer-events:auto;transition:width .14s ease .2s,opacity .12s ease .2s,visibility 0s linear .2s}
793
+ .dsh-np-actiongroup:focus-within .dsh-np-group-extra{width:24px!important;opacity:1;visibility:visible;pointer-events:auto;transition-delay:0s}
791
794
  .dsh-np-actiongroup[data-size=bar]:hover .dsh-np-group-extra,.dsh-np-actiongroup[data-size=bar]:focus-within .dsh-np-group-extra{width:27px!important}
792
- .dsh-np-seg-text{display:block;width:100%;box-sizing:border-box;padding:2px 10px 8px;border:0;outline:0;resize:none;background:transparent;color:inherit;font-family:${FONT_STACK};font-size:13px;line-height:1.6;min-height:26px;max-height:240px;overflow-y:auto;white-space:pre-wrap;overflow-wrap:anywhere;tab-size:2}
795
+ .dsh-np-seg-text{display:block;width:100%;box-sizing:border-box;padding:2px 10px 8px;border:0;outline:0;resize:none;background:transparent;color:inherit;font-family:${FONT_STACK};font-size:13px;line-height:1.6;min-height:26px;max-height:240px;overflow-y:hidden;white-space:pre-wrap;overflow-wrap:anywhere;tab-size:2}
793
796
  .dsh-np-seg-text::placeholder{color:var(--dsw-alias-label-caption,#9aa3b2)}
794
797
  .dsh-np-seg-text:disabled{opacity:.6}
795
798
  .dsh-np-file{display:none}
@@ -1039,17 +1042,43 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1039
1042
  return count;
1040
1043
  }
1041
1044
 
1042
- /** Whether a whole server payload predates per-segment creation times. */
1043
- function isLegacyPayload(data) {
1044
- const segments = Array.isArray(data?.segments) ? data.segments : [];
1045
- return segments.length > 0 && segments.every((segment) =>
1046
- segment !== null && typeof segment === 'object'
1047
- && (typeof segment.createdAt !== 'string' || segment.createdAt === ''));
1045
+ /** A segment with no title, text, or images can be deleted without a prompt. */
1046
+ function isEmptySegment(segment) {
1047
+ if (segment === null || segment === undefined) return false;
1048
+ const title = typeof segment.title === 'string' ? segment.title.trim() : '';
1049
+ const text = typeof segment.text === 'string' ? segment.text.trim() : '';
1050
+ const images = Array.isArray(segment.images) ? segment.images : [];
1051
+ return title === '' && text === '' && images.length === 0;
1052
+ }
1053
+
1054
+ /** Merge image references in order, dropping duplicates and respecting the cap. */
1055
+ function collectImages(segments) {
1056
+ const seen = new Set();
1057
+ const images = [];
1058
+ for (const segment of segments) {
1059
+ for (const image of Array.isArray(segment?.images) ? segment.images : []) {
1060
+ if (seen.has(image.path) || images.length >= MAX_IMAGES) continue;
1061
+ seen.add(image.path);
1062
+ images.push(image);
1063
+ }
1064
+ }
1065
+ return images;
1066
+ }
1067
+
1068
+ /**
1069
+ * Decode a GET /note payload. Version 4 (0.4–0.6) stored newest-first;
1070
+ * version 5 stores oldest-first, so a v4 list is reversed once for display.
1071
+ */
1072
+ function readServerSegments(data) {
1073
+ let value = normalizeServerSegments(data);
1074
+ if (data?.version === 4) value = value.slice().reverse();
1075
+ return value;
1048
1076
  }
1049
1077
 
1050
1078
  /**
1051
1079
  * Adopt server content without moving cards the user is looking at.
1052
- * Known ids keep the local order; cards created elsewhere land on top.
1080
+ * Known ids keep the local order; cards created elsewhere land at the
1081
+ * bottom, matching the oldest-to-newest display direction.
1053
1082
  */
1054
1083
  function mergeServerOrder(current, incoming) {
1055
1084
  const byId = new Map();
@@ -1061,7 +1090,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1061
1090
  }
1062
1091
  const known = new Set(kept.map((segment) => segment.id));
1063
1092
  const added = incoming.filter((segment) => !known.has(segment.id));
1064
- return [...added, ...kept];
1093
+ return [...kept, ...added];
1065
1094
  }
1066
1095
 
1067
1096
  function isAbsolutePath(value) {
@@ -1188,7 +1217,11 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1188
1217
  element.style.height = 'auto';
1189
1218
  const height = Number(element.scrollHeight);
1190
1219
  if (Number.isFinite(height) && height > 0) {
1191
- element.style.height = `${Math.min(240, Math.max(26, height))}px`;
1220
+ const capped = height > MAX_SEGMENT_HEIGHT;
1221
+ element.style.height = `${Math.min(MAX_SEGMENT_HEIGHT, Math.max(26, height))}px`;
1222
+ // Keep the native scrollbar hidden until the card really reaches
1223
+ // its limit; otherwise CSS overflow:auto can reserve a gutter early.
1224
+ element.style.overflowY = capped ? 'auto' : 'hidden';
1192
1225
  }
1193
1226
  } catch {
1194
1227
  /* A non-layout shim (tests) may not expose scrollHeight. */
@@ -1715,10 +1748,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1715
1748
  fetchNote(root, binding.id)
1716
1749
  .then((data) => {
1717
1750
  if (cancelled || rootRef.current !== root || mySeq !== loadSeqRef.current) return;
1718
- let value = normalizeServerSegments(data);
1719
- // Notes written before 0.4 appended new cards at the bottom;
1720
- // reverse that legacy order once so the newest card leads.
1721
- if (isLegacyPayload(data)) value = value.slice().reverse();
1751
+ let value = readServerSegments(data);
1722
1752
  if (value.length === 0) value = [newSegment('')];
1723
1753
  segmentsRef.current = value;
1724
1754
  setSegments(value);
@@ -1886,8 +1916,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1886
1916
  return;
1887
1917
  }
1888
1918
  if (!resetShortcutHandledByHost
1889
- && (event.key === '0' || event.key === ')' || event.code === 'Digit0'
1890
- || event.code === 'Numpad0' || event.keyCode === 48)) {
1919
+ && (event.key === 'R' || event.key === 'r' || event.code === 'KeyR')) {
1891
1920
  event.preventDefault();
1892
1921
  resetGeometry();
1893
1922
  }
@@ -1965,8 +1994,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1965
1994
  pendingScrollRef.current = null;
1966
1995
  return;
1967
1996
  }
1968
- let incoming = normalizeServerSegments(data);
1969
- if (isLegacyPayload(data)) incoming = incoming.slice().reverse();
1997
+ let incoming = readServerSegments(data);
1970
1998
  if (incoming.length === 0) incoming = [newSegment('')];
1971
1999
  const value = mergeServerOrder(segmentsRef.current, incoming);
1972
2000
  segmentsRef.current = value;
@@ -2221,7 +2249,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2221
2249
  return;
2222
2250
  }
2223
2251
  const segment = newSegment('');
2224
- commitSegments([segment, ...segmentsRef.current], { focus: { id: segment.id, caret: 0 } });
2252
+ commitSegments([...segmentsRef.current, segment], { focus: { id: segment.id, caret: 0 } });
2225
2253
  };
2226
2254
 
2227
2255
  const splitSegment = (id, element) => {
@@ -2233,19 +2261,13 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2233
2261
  const nextSegment = newSegment(tail);
2234
2262
  const updated = segmentsRef.current.map((segment) =>
2235
2263
  (segment.id === id ? { ...segment, text: head } : segment));
2236
- // Enter at the end creates a fresh card, which belongs at the top.
2237
- // Splitting in the middle keeps the tail next to its source card so
2238
- // the caret does not jump away from what the user is editing.
2239
- const next = tail.trim() === ''
2240
- ? [nextSegment, ...updated]
2241
- : (() => {
2242
- const out = [];
2243
- for (const segment of updated) {
2244
- out.push(segment);
2245
- if (segment.id === id) out.push(nextSegment);
2246
- }
2247
- return out;
2248
- })();
2264
+ // Cards are displayed oldest-first, so the split tail always follows
2265
+ // its source card (also for an empty tail at the end of the note).
2266
+ const next = [];
2267
+ for (const segment of updated) {
2268
+ next.push(segment);
2269
+ if (segment.id === id) next.push(nextSegment);
2270
+ }
2249
2271
  commitSegments(next, { focus: { id: nextSegment.id, caret: 0 } });
2250
2272
  };
2251
2273
 
@@ -2363,15 +2385,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2363
2385
  for (let index = 0; index < firstIndex; index += 1) {
2364
2386
  if (!ids.has(segmentsRef.current[index].id)) insertAt += 1;
2365
2387
  }
2366
- const seenPaths = new Set();
2367
- const images = [];
2368
- for (const segment of chosen) {
2369
- for (const image of Array.isArray(segment.images) ? segment.images : []) {
2370
- if (seenPaths.has(image.path) || images.length >= MAX_IMAGES) continue;
2371
- seenPaths.add(image.path);
2372
- images.push(image);
2373
- }
2374
- }
2388
+ const images = collectImages(chosen);
2375
2389
  const merged = {
2376
2390
  id: newSegmentId(),
2377
2391
  text: chosen.map((segment) => segment.text).filter((text) => text.trim() !== '').join('\n\n'),
@@ -2977,6 +2991,43 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2977
2991
  return true;
2978
2992
  };
2979
2993
 
2994
+ /** Remove a content-empty segment and keep a nearby typing surface. */
2995
+ const deleteEmptySegment = (id, previous, next) => {
2996
+ const remaining = segmentsRef.current.filter((segment) => segment.id !== id);
2997
+ const activeRemaining = remaining.filter((segment) => segment.archived !== true);
2998
+ if (activeRemaining.length === 0) {
2999
+ const fresh = newSegment('');
3000
+ commitSegments([fresh], { focus: { id: fresh.id, caret: 0 } });
3001
+ return;
3002
+ }
3003
+ const focus = previous ?? next;
3004
+ if (focus === undefined) {
3005
+ commitSegments(remaining);
3006
+ return;
3007
+ }
3008
+ const caret = previous !== undefined ? previous.text.length : 0;
3009
+ commitSegments(remaining, { focus: { id: focus.id, caret } });
3010
+ };
3011
+
3012
+ /** Backspace at position 0 joins this segment onto the previous card. */
3013
+ const mergeWithPrevious = (previous, current) => {
3014
+ const title = [previous.title, current.title]
3015
+ .map((value) => (typeof value === 'string' ? value.trim() : ''))
3016
+ .filter((value) => value !== '')
3017
+ .join(' · ')
3018
+ .slice(0, MAX_TITLE_CHARS);
3019
+ const merged = {
3020
+ ...previous,
3021
+ text: `${previous.text}${current.text}`,
3022
+ title,
3023
+ images: collectImages([previous, current]),
3024
+ };
3025
+ const next = segmentsRef.current
3026
+ .filter((segment) => segment.id !== current.id)
3027
+ .map((segment) => (segment.id === previous.id ? merged : segment));
3028
+ commitSegments(next, { focus: { id: previous.id, caret: previous.text.length } });
3029
+ };
3030
+
2980
3031
  const onSegmentKeyDown = (event, id) => {
2981
3032
  if (event.isComposing === true || event.nativeEvent?.isComposing === true) return;
2982
3033
  const chord = event.ctrlKey || event.metaKey;
@@ -3013,6 +3064,27 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
3013
3064
  }
3014
3065
  return;
3015
3066
  }
3067
+ if (event.key === 'Backspace') {
3068
+ if (event.altKey || event.shiftKey || chord) return;
3069
+ const element = event.currentTarget ?? event.target;
3070
+ const start = typeof element?.selectionStart === 'number' ? element.selectionStart : null;
3071
+ const end = typeof element?.selectionEnd === 'number' ? element.selectionEnd : start;
3072
+ if (start !== 0 || end !== 0) return;
3073
+ const active = segmentsRef.current.filter((segment) => segment.archived !== true);
3074
+ const position = active.findIndex((segment) => segment.id === id);
3075
+ const current = position < 0 ? undefined : active[position];
3076
+ if (current === undefined) return;
3077
+ const previous = position > 0 ? active[position - 1] : undefined;
3078
+ if (isEmptySegment(current)) {
3079
+ event.preventDefault?.();
3080
+ deleteEmptySegment(current.id, previous, active[position + 1]);
3081
+ return;
3082
+ }
3083
+ if (previous === undefined) return;
3084
+ event.preventDefault?.();
3085
+ mergeWithPrevious(previous, current);
3086
+ return;
3087
+ }
3016
3088
  if (event.key !== 'Enter' || event.shiftKey || event.altKey || chord) return;
3017
3089
  event.preventDefault();
3018
3090
  splitSegment(id, event.currentTarget ?? event.target);
@@ -3131,7 +3203,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
3131
3203
  {
3132
3204
  type: 'button',
3133
3205
  className: 'dsh-np-btn',
3134
- title: `${t('resetShortcut')} · Ctrl+Shift+0`,
3206
+ title: `${t('resetShortcut')} · Ctrl+Shift+R`,
3135
3207
  'aria-label': t('resetShortcut'),
3136
3208
  onClick: resetGeometry,
3137
3209
  },
@@ -3209,22 +3281,20 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
3209
3281
  onClick: primary.onClick,
3210
3282
  };
3211
3283
  if (primary.accent === true) primaryProps['data-accent'] = 'true';
3284
+ const extraProps = {
3285
+ type: 'button',
3286
+ className: `${base} dsh-np-group-extra`,
3287
+ title: extra.title,
3288
+ 'aria-label': extra.title,
3289
+ disabled,
3290
+ onClick: extra.onClick,
3291
+ };
3292
+ if (extra.danger === true) extraProps['data-danger'] = 'true';
3212
3293
  return h(
3213
3294
  'div',
3214
3295
  { className: 'dsh-np-actiongroup', 'data-size': size },
3215
3296
  h('button', primaryProps, primary.icon),
3216
- h(
3217
- 'button',
3218
- {
3219
- type: 'button',
3220
- className: `${base} dsh-np-group-extra`,
3221
- title: extra.title,
3222
- 'aria-label': extra.title,
3223
- disabled,
3224
- onClick: extra.onClick,
3225
- },
3226
- h(NewSessionIcon, { size: 14 }),
3227
- ),
3297
+ h('button', extraProps, extra.icon ?? h(NewSessionIcon, { size: 14 })),
3228
3298
  );
3229
3299
  };
3230
3300
 
@@ -3585,29 +3655,23 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
3585
3655
  ? h('span', { className: 'dsh-np-mini-spinner' })
3586
3656
  : canUndoOptimize ? h(UndoIcon, { size: 14 }) : h(SparkleIcon, { size: 14 }),
3587
3657
  ),
3588
- h(
3589
- 'button',
3590
- {
3591
- type: 'button',
3592
- className: 'dsh-np-segbtn',
3658
+ actionGroup({
3659
+ size: 'card',
3660
+ primary: {
3593
3661
  title: t('archiveSegment'),
3594
- 'aria-label': t('archiveSegment'),
3595
3662
  onClick: () => archiveSegment(segment.id),
3663
+ icon: h(ArchiveIcon, { size: 14 }),
3596
3664
  },
3597
- h(ArchiveIcon, { size: 14 }),
3598
- ),
3599
- h(
3600
- 'button',
3601
- {
3602
- type: 'button',
3603
- className: 'dsh-np-segbtn',
3604
- 'data-danger': 'true',
3665
+ extra: {
3605
3666
  title: t('deleteActiveSegment'),
3606
- 'aria-label': t('deleteActiveSegment'),
3607
- onClick: () => setDeleteConfirm(segment.id),
3667
+ danger: true,
3668
+ onClick: () => {
3669
+ if (isEmptySegment(segment)) deleteActiveSegment(segment.id);
3670
+ else setDeleteConfirm(segment.id);
3671
+ },
3672
+ icon: h(TrashIcon, { size: 14 }),
3608
3673
  },
3609
- h(TrashIcon, { size: 14 }),
3610
- ),
3674
+ }),
3611
3675
  ),
3612
3676
  h(
3613
3677
  'div',
@@ -3632,7 +3696,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
3632
3696
  className: 'dsh-np-seg-text',
3633
3697
  rows: 1,
3634
3698
  value: segment.text,
3635
- placeholder: index === 0 ? t('placeholder') : t('segmentPlaceholder'),
3699
+ placeholder: index === activeSegments.length - 1 ? t('placeholder') : t('segmentPlaceholder'),
3636
3700
  spellCheck: false,
3637
3701
  maxLength: MAX_SEGMENT_CHARS,
3638
3702
  'aria-label': t('editor'),
@@ -4055,12 +4119,12 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
4055
4119
  label: () => translate('resetShortcut'),
4056
4120
  aliases: ['reset notepad', 'reset notepad geometry'],
4057
4121
  defaults: {
4058
- 'desktop:macos': { code: 'Digit0', modifiers: ['primary', 'shift'] },
4059
- 'desktop:windows': { code: 'Digit0', modifiers: ['primary', 'shift'] },
4060
- 'desktop:linux': { code: 'Digit0', modifiers: ['primary', 'shift'] },
4061
- 'web:macos': { code: 'Digit0', modifiers: ['primary', 'shift'] },
4062
- 'web:windows': { code: 'Digit0', modifiers: ['primary', 'shift'] },
4063
- 'web:linux': { code: 'Digit0', modifiers: ['primary', 'shift'] },
4122
+ 'desktop:macos': { code: 'KeyR', modifiers: ['primary', 'shift'] },
4123
+ 'desktop:windows': { code: 'KeyR', modifiers: ['primary', 'shift'] },
4124
+ 'desktop:linux': { code: 'KeyR', modifiers: ['primary', 'shift'] },
4125
+ 'web:macos': { code: 'KeyR', modifiers: ['primary', 'shift'] },
4126
+ 'web:windows': { code: 'KeyR', modifiers: ['primary', 'shift'] },
4127
+ 'web:linux': { code: 'KeyR', modifiers: ['primary', 'shift'] },
4064
4128
  },
4065
4129
  regions: ['page', 'editable'],
4066
4130
  modals: [],
package/index.js CHANGED
@@ -38,7 +38,7 @@ import {
38
38
  const ROUTE = '/dsh-notepad/api';
39
39
 
40
40
  /** Plugin version mirrored in package.json. */
41
- const VERSION = '0.6.0';
41
+ const VERSION = '0.7.0';
42
42
 
43
43
  /** Request body cap for note writes. */
44
44
  const BODY_LIMIT = 1024 * 1024;
package/lib/note-store.js CHANGED
@@ -3,13 +3,16 @@
3
3
  *
4
4
  * One note per workspace, kept at `<workspace>/.dsh/notepad.json`. A note is an
5
5
  * ordered list of text segments; each segment may be archived after it was
6
- * sent, and only archived segments can be deleted by the client.
6
+ * sent, and the Client may delete either archived or active segments (active
7
+ * deletion is irreversible and confirmed in the UI).
7
8
  *
8
9
  * Version 4 adds an optional card title and creation timestamp to every
9
- * segment. Version 2 stores the segment list under `segments` and repeats the
10
- * merged body under `text` so the file stays human readable and older builds
11
- * can still read the content. Version 1 files (a single `text`) and
12
- * hand-edited plain-text files are migrated on read as one segment.
10
+ * segment. Version 5 stores cards oldest-first; the Client reverses a version 4
11
+ * payload once while reading it. Version 2 stores the segment list under
12
+ * `segments` and repeats the merged body under `text` so the file stays human
13
+ * readable and older builds can still read the content. Version 1 files (a
14
+ * single `text`) and hand-edited plain-text files are migrated on read as one
15
+ * segment.
13
16
  *
14
17
  * @module dsh-notepad/lib/note-store
15
18
  */
@@ -39,8 +42,8 @@ export const MAX_SEGMENT_TITLE_CHARS = 200;
39
42
  /** Maximum characters kept for one creation timestamp. */
40
43
  export const MAX_SEGMENT_TIME_CHARS = 64;
41
44
 
42
- /** Storage envelope version. */
43
- export const NOTE_VERSION = 4;
45
+ /** Storage envelope version. Version 5 flips the default card order to oldest-first. */
46
+ export const NOTE_VERSION = 5;
44
47
 
45
48
  /** Maximum image references accepted on one segment. */
46
49
  export const MAX_IMAGES_PER_SEGMENT = 20;
@@ -358,9 +361,11 @@ export function mergeSegments(segments) {
358
361
  export function parseNote(raw, mtimeMs) {
359
362
  let segmentsInput;
360
363
  let updatedAt;
364
+ let version = null;
361
365
  try {
362
366
  const parsed = JSON.parse(raw);
363
367
  if (parsed !== null && typeof parsed === 'object') {
368
+ if (Number.isFinite(parsed.version)) version = parsed.version;
364
369
  if (Array.isArray(parsed.segments)) segmentsInput = parsed.segments;
365
370
  else if (typeof parsed.text === 'string') segmentsInput = parsed.text;
366
371
  if (typeof parsed.updatedAt === 'string') updatedAt = parsed.updatedAt;
@@ -379,6 +384,7 @@ export function parseNote(raw, mtimeMs) {
379
384
  if (segment !== null) segments.push(segment);
380
385
  }
381
386
  return {
387
+ version,
382
388
  segments,
383
389
  text: mergeSegments(segments),
384
390
  updatedAt: updatedAt ?? (typeof mtimeMs === 'number' ? new Date(mtimeMs).toISOString() : null),
@@ -400,7 +406,7 @@ export async function readNote(root) {
400
406
  raw = await fs.readFile(file, 'utf8');
401
407
  } catch (error) {
402
408
  if (error?.code === 'ENOENT') {
403
- return { segments: [], text: '', updatedAt: null, mtimeMs: null, exists: false, size: 0 };
409
+ return { version: null, segments: [], text: '', updatedAt: null, mtimeMs: null, exists: false, size: 0 };
404
410
  }
405
411
  throw error;
406
412
  }
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Notepad",
4
- "description": "A per-workspace floating notepad in the session view. New segment cards lead the list; an empty title is just a blank click target until clicked. Click an index to select, drag to reorder, merge a selection, move between cards with the arrow keys, and improve only the selected cards with AI — the button turns into undo and identical content reuses the previous answer. Insert and send buttons expand on hover into current-session / new-session groups, while replace stays a single button. Image chips open an in-panel preview; active segments can be deleted directly after a confirmation that warns the action cannot be undone. The footer shows one shortcut tip at a time and rotates on click; copy-all skips archived segments; sending follows the General Settings busy-send policy. Pending counts appear on the hidden launcher and the sidebar workspace row, auto refresh keeps the list scroll position, Ctrl+Shift+0 (or the header reset button) resets the panel geometry, and the panel never covers the host title bar."
4
+ "description": "A per-workspace floating notepad in the session view. Cards run oldest-to-newest, Enter splits after the source card, Backspace at the start of a card merges it into the previous one, and an empty card is deleted immediately (Backspace in it also removes it). An empty title is just a blank click target until clicked. Click an index to select, drag to reorder, merge a selection, move between cards with the arrow keys, and improve only the selected cards with AI — the button turns into undo and identical content reuses the previous answer. Insert, send and archive+delete groups wait briefly before expanding on hover; insert/send include current-session and new-session actions, while replace and archive stay single-click primaries. Image chips open an in-panel preview; non-empty delete asks for confirmation and warns the action cannot be undone. The footer shows one shortcut tip at a time and rotates on click; copy-all skips archived segments; sending follows the General Settings busy-send policy. Pending counts appear on the hidden launcher and the sidebar workspace row, auto refresh keeps the list scroll position, the textarea only shows a scrollbar at its height limit, Ctrl+Shift+R (or the header reset button) resets the panel geometry, and the panel never covers the host title bar."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "记事本",
4
- "description": "在会话界面显示一个按工作区共享的分段便签浮窗:新分段置顶,空标题点击后才显示输入框,点击序号选中,可拖拽排序、合并多段、用上下方向键切换分段;AI 优化只作用于选中的分段,优化后按钮变为撤销,相同内容直接复用上次结果;插入 / 发送按钮 hover 时横向展开为「当前会话 / 新会话」按钮组,替换按钮保持单键;图片支持面板内预览;可直接删除分段,但需弹窗二次确认并提示无法恢复;底部只显示一条快捷键提示,点击轮换;复制全部不包含归档分段;发送行为跟随「设置 > 通用设置」中的繁忙发送策略;支持粘贴或选择图片并以路径附件形式引入;隐藏按钮和侧边栏工作区显示未发送数量角标;自动刷新保持滚动位置,浮窗可调整尺寸,Ctrl+Shift+0 或标题栏复位按钮可复位位置和尺寸,且不会越过窗口标题栏。"
4
+ "description": "在会话界面显示一个按工作区共享的分段便签浮窗:分段从旧到新、从上到下排列,回车拆分后新卡片紧跟原卡片,在段首按 Backspace 会与上一段合并;空标题点击后才显示输入框;点击序号选中,可拖拽排序、合并多段、用上下方向键切换分段;AI 优化只作用于选中的分段,优化后按钮变为撤销,相同内容复用上次结果;插入、发送、归档+删除按钮 hover 稍候才横向展开为细圆角按钮组(插入/发送含当前会话与新会话,替换保持单键);图片支持面板内预览;空分段删除无需确认,空段内按 Backspace 自动删除,其它分段删除需二次确认并提示无法恢复;底部只显示一条快捷键提示,点击轮换;复制全部不包含归档分段;发送行为跟随「设置 > 通用设置」中的繁忙发送策略;隐藏按钮和侧边栏工作区显示未发送数量角标;自动刷新保持滚动位置,分段文本达到最大高度才出现滚动条;浮窗可调整尺寸,Ctrl+Shift+R 或标题栏复位按钮可复位位置和尺寸,且不会越过窗口标题栏。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@restregular/dsh-notepad",
3
- "version": "0.6.0",
4
- "description": "Notepad — a per-workspace floating notepad in the DeepSeek Harness session view: newest segment cards first with click-to-edit titles, drag to reorder, merge selected segments, arrow-key navigation, AI improve limited to selected segments with undo and cached results, hover insert / send groups that also target a fresh session, in-panel image previews, direct delete with a confirmation, rotating shortcut tips, copy-all that skips archived segments, the General Settings busy-send policy, image chips with automatic file cleanup, pending-count badges on the hidden button and sidebar workspace, scroll-safe auto refresh, resizable title-bar-safe panel, Ctrl+Shift+0 geometry reset, and dark-mode styling. · 记事本 — 在 DeepSeek Harness 会话界面显示一个按工作区共享的分段便签浮窗:新分段置顶且空标题只在点击后显示输入框,可拖拽排序、合并选中分段、上下方向键切换分段、只对选中分段做 AI 优化,优化后按钮变为撤销并复用缓存结果;插入 / 发送按钮 hover 时展开为「当前会话 / 新会话」按钮组,图片支持面板内预览,可直接删除分段但需二次确认,底部快捷键提示可点击轮换,复制全部不再包含归档分段,发送行为跟随「设置 > 通用设置」中的繁忙发送策略;图片芯片删除时自动清理磁盘文件;隐藏按钮与侧边栏工作区显示未发送数量角标;自动刷新保持列表滚动位置;可调整尺寸,Ctrl+Shift+0 复位位置和尺寸,浮窗不会越过窗口标题栏,并适配深色模式。",
3
+ "version": "0.7.0",
4
+ "description": "Notepad — a per-workspace floating notepad in the DeepSeek Harness session view: oldest-first segment cards with click-to-edit titles, Enter split after the source, Backspace merge into the previous card, drag to reorder, merge selected segments, arrow-key navigation, AI improve with undo and cached results, delayed hover groups for insert / send / archive-delete (including fresh-session variants), in-panel image previews, empty-segment deletion without a prompt, rotating shortcut tips, copy-all that skips archived segments, the General Settings busy-send policy, image chips with automatic file cleanup, pending-count badges on the hidden button and sidebar workspace, scroll-safe auto refresh, a textarea that only scrolls at its height limit, resizable title-bar-safe panel, Ctrl+Shift+R geometry reset, and dark-mode styling. · 记事本 — 在 DeepSeek Harness 会话界面显示一个按工作区共享的分段便签浮窗:分段按从旧到新、从上到下排列,回车拆分后新卡片紧跟原卡片,在段首按 Backspace 会与上一段合并;空标题只在点击后显示输入框;可拖拽排序、合并选中分段、上下方向键切换分段、只对选中分段做 AI 优化并可撤销 / 复用缓存;插入、发送、归档+删除按钮 hover 稍候才展开为细边框按钮组(插入/发送含新会话变体);图片支持面板内预览;空分段删除无需确认,空段内按 Backspace 自动删除;底部快捷键提示点击轮换;复制全部排除归档;发送跟随「设置 > 通用设置」的繁忙发送策略;图片芯片删除时自动清理磁盘文件;隐藏按钮与侧边栏工作区显示未发送数量角标;自动刷新保持滚动位置;分段文本到最大高度才出现滚动条;可调整尺寸,Ctrl+Shift+R 复位位置和尺寸,浮窗不会越过窗口标题栏,并适配深色模式。",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
7
7
  "main": "index.js",