@restregular/dsh-notepad 0.4.0 → 0.5.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 起新分段置顶、卡片可写标题、点击序号选中、拖拽排序、合并多段,并可用 AI 优化单段或全部分段;隐藏时记事本和侧边栏工作区都会显示未发送数量角标。
3
+ 在 DeepSeek Harness 的会话界面里放一张**属于当前工作区**的分段便签浮窗:一行一个问题、一段一个卡片,回车就能新建下一段;需要时把某一段、或勾选后的多段一起插入 / 替换 / 发送到对话框。除了文字,也可以直接**粘贴或选择图片**,输入框里会用缩略芯片显示,插入 / 发送时按路径引用。0.4 起新分段置顶、点击序号选中、拖拽排序、合并多段;0.5 起空标题只在点击后显示输入框,上下方向键可切换分段,AI 优化只处理选中的分段且优化后按钮变为「撤销优化」,相同内容复用上次结果;自动刷新保持列表滚动位置,新增 `Ctrl/Cmd + Shift + 0` 复位浮窗位置和尺寸,浮窗也不会再越过窗口标题栏。
4
4
 
5
- > A per-workspace floating segmented notepad for DeepSeek Harness sessions. New segment cards lead the list, each card has an editable title, click the index to select, drag to reorder, merge a selection, and improve one or all cards with AI. Pending counts appear on the hidden launcher and the sidebar workspace row. Paste or pick images as in-input thumbnail chips; insert, replace, or send one segment or a multi-selection; sending archives the segment automatically. Notes live in `<workspace>/.dsh/notepad.json`, pasted images in `<workspace>/.dsh/notepad/images/`, and every session opened from that workspace shares the same note.
5
+ > A per-workspace floating segmented notepad for DeepSeek Harness sessions. New segment cards lead the list; an empty title is a click-to-edit placeholder, 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. Pending counts appear on the hidden launcher and the sidebar workspace row. Paste or pick images as in-input thumbnail chips; insert, replace, or send one segment or a multi-selection; sending archives the segment automatically. Auto refresh preserves the list scroll position, `Ctrl/Cmd + Shift + 0` resets the window 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
 
@@ -31,9 +31,11 @@ dsh plugin --profile web add @restregular/dsh-notepad
31
31
  ```
32
32
 
33
33
  - **新建分段置顶**:标题栏 `+` 或分段末尾回车会在列表顶部创建新卡片;卡片从上到下保持最近在前,拖拽后按你的顺序保存。
34
- - **标题**:序号右边的输入框就是分段标题,边输入边自动保存。
34
+ - **标题**:空标题只显示「分段标题(可选)」占位,没有 hover 底色;点击后才出现输入框。已有标题的卡片直接显示输入框,边输入边自动保存;在输入框里清空后不会立刻消失,失焦才回到占位状态。
35
35
  - **序号**:显示当前顺序(`01`、`02`…),点击可选中 / 取消选中这一段,拖动序号可调整卡片顺序。
36
- - **AI 优化**:卡片工具栏的 ✦ 优化此段;顶部工具栏的 ✦ 优化全部非空分段;结果可撤销。
36
+ - **AI 优化**:勾选分段后,顶部工具栏的 ✦ 只优化选中的分段;卡片工具栏的 ✦ 优化此段。优化成功后,刚点过的按钮变成「撤销优化」,再点一次恢复原文;相同且未改动的标题 + 内容再次优化时直接复用上次结果,不再调用大模型。
37
+ - **方向键**:光标在分段首行按 `↑` 跳到上一张卡片末尾;在末行按 `↓` 跳到下一张卡片开头。多行分段内部仍按正常文本光标移动。
38
+ - **复位浮窗**:`Ctrl / Cmd + Shift + 0` 把位置和尺寸恢复为右下角的默认 `420 × 460`,最小化 / 归档展开等状态不变。
37
39
  - **合并**:勾选 2 段以上后点顶部工具栏的合并按钮,合并结果留在最靠上的选中位置。
38
40
  - **回车 / Enter**:把当前分段从光标处拆开,创建一张新的分段卡片(光标前的文字留在原卡片,光标后的文字进入新卡片)。在末尾回车就是新建一个空分段并置顶。
39
41
  - **Shift + Enter**:分段内换行。
@@ -46,12 +48,14 @@ dsh plugin --profile web add @restregular/dsh-notepad
46
48
  | --- | --- | --- |
47
49
  | 新建分段 | 标题栏 `+`,或分段末尾按回车 | 新卡片置顶并自动获得焦点 |
48
50
  | 编辑分段 | 点击卡片文本区 | 停止输入约 0.5 秒后自动保存 |
49
- | 分段标题 | 序号右侧的标题输入框 | 可留空;停止输入约 0.5 秒后自动保存 |
51
+ | 分段标题 | 空标题卡片的占位 / 已有标题的输入框 | 空标题点击后才出现输入框;停止输入约 0.5 秒后自动保存 |
50
52
  | 选中分段 | 点击左上角序号,或左侧复选框 | 序号与复选框状态同步;点击序号即可参与批量操作 |
51
53
  | 拖拽排序 | 按住左上角序号拖动到另一张卡片的上 / 下半区 | 松开后按新顺序保存,序号自动重排 |
52
54
  | 合并分段 | 勾选 2 段以上 → 顶部工具栏合并按钮 | 按当前显示顺序合并文字、标题和图片,可撤销 |
53
- | AI 优化此段 | 卡片工具栏的 ✦ 按钮 | 用当前默认模型润色这一段,保留图片;可撤销 |
54
- | AI 优化全部 | 顶部工具栏的 ✦ 按钮 | 依次优化所有非空未归档分段,显示进度;可撤销 |
55
+ | AI 优化此段 | 卡片工具栏的 ✦ 按钮 | 用当前默认模型润色这一段,保留图片;成功后按钮变为「撤销优化」 |
56
+ | AI 优化选中 | 勾选分段 → 顶部工具栏的 ✦ 按钮 | 只优化勾选的非空未归档分段,显示进度;按钮变为「撤销优化」,可整体撤销 |
57
+ | 撤销优化 | 优化后的 ✦ 按钮(或顶部工具栏) | 恢复优化前的文字;再次优化相同内容时直接复用上次结果 |
58
+ | 方向键切换 | 分段文本内按 `↑` / `↓` | 首行 `↑` 到上一段末尾,末行 `↓` 到下一段开头 |
55
59
  | 未发送角标 | 最小化后的右下角图标;左侧边栏工作区行左上角 | 有未发送内容时显示数量,超过 99 显示 `99+` |
56
60
  | 添加图片(选择) | 卡片头部的图片按钮 | 桌面端直接用所选文件的宿主路径引入,不复制文件 |
57
61
  | 添加图片(粘贴) | 在分段文本区粘贴截图 / 图片 | 写入 `.dsh/notepad/images/`,再以该路径引入 |
@@ -71,9 +75,10 @@ dsh plugin --profile web add @restregular/dsh-notepad
71
75
  | 清空归档 | 归档区标题「清空归档」 | 批量删除归档,可撤销 |
72
76
  | 复制全部 | 标题栏复制按钮 | 复制所有分段(含归档),用空行合并 |
73
77
  | 调整尺寸 | 拖动右下角斜纹手柄 | 宽高按浏览器记忆,最小 320 × 240 |
78
+ | 复位位置和尺寸 | `Ctrl/Cmd + Shift + 0` | 恢复默认 420 × 460 并停到右下角,其它状态不变 |
74
79
  | 折叠 / 展开 | 标题栏上下折叠箭头 | 只保留标题栏,内容继续自动保存 |
75
80
  | 最小化 / 恢复 | 标题栏最小化图标,或 `Ctrl/Cmd + Shift + N` | 收成右下角小图标,带平滑过渡 |
76
- | 拖动位置 | 按住标题栏拖动 | 位置按浏览器记忆 |
81
+ | 拖动位置 | 按住标题栏拖动 | 位置按浏览器记忆;不会越过 DSH 窗口最顶部的标题栏(最大化 / 最小化 / 关闭那一栏) |
77
82
 
78
83
  同一个工作区里的不同会话共用同一份笔记:在 A 会话写的内容,切到同工作区的 B 会话仍然在。切换工作区时浮窗自动加载对应工作区的文件。
79
84
 
@@ -114,17 +119,19 @@ dsh plugin --profile web add @restregular/dsh-notepad
114
119
  `text` 是 `segments`(含图片引用)用空行合并后的副本,方便人工查看,也兼容旧版本。旧版单段文件(只有 `text`)、v2/v3 分段文件和手动改成纯文本的文件会在读取时迁移;没有 `createdAt` 的旧笔记会按「最近在前」反转一次显示顺序,之后按卡片数组顺序保存(拖拽后的顺序不会被自动重排)。`images[].path` 工作区内用相对路径,工作区外保留绝对路径;`name` 仅用于显示和 Markdown 替代文本。`title` 最长 200 字符。
115
120
  - **粘贴的图片**保存在 `<工作区>/.dsh/notepad/images/`,由宿主接口原子写入,只接受 PNG / JPEG / WebP / GIF,单张最大 20 MB;笔记只保存路径,不内嵌 base64。**选择的图片**直接引用原路径,不复制。每段最多 20 张。
116
121
  - **删除图片芯片**会同时删除插件自己保存在 `.dsh/notepad/images/` 下的对应文件;引用工作区外原路径的图片只删引用,不碰原文件。删除已归档分段 / 清空归档时,未被其它分段引用的插件图片会在撤销窗口结束后自动清理。
117
- - 浮窗位置、尺寸、折叠 / 最小化 / 隐藏状态、归档区是否展开保存在浏览器 `localStorage` 的 `dsh-notepad.ui.v1` 中,按浏览器 / 设备生效,不写入工作区。
122
+ - 浮窗位置、尺寸、折叠 / 最小化 / 隐藏状态、归档区是否展开保存在浏览器 `localStorage` 的 `dsh-notepad.ui.v1` 中,按浏览器 / 设备生效,不写入工作区。位置会被限制在宿主标题栏下方(读取 DSH 的 `--dsh-frame-chrome-top` / `--dsh-frame-top-clearance`),`Ctrl/Cmd + Shift + 0` 可随时复位位置和尺寸。
118
123
  - 每个工作区一份笔记,最多 **200 段 / 512 KB**(单段输入框限制 16 万字符,每段最多 20 张图片)。浮窗内是纯文本编辑,不做 Markdown 渲染;图片以缩略芯片展示。
119
124
  - 浮窗只在**会话视图**出现:打开设置等非会话页面时自动隐藏,回到会话后恢复。
120
125
  - 写入走宿主同源回环路由 `/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`,不会影响记事本的其它功能。
121
- - 窗口重新获得焦点时,如果本地没有未保存的修改,会自动读取磁盘上的最新内容;多窗口同时编辑时以最后一次保存为准,读取时保留当前卡片顺序,不会让正在看的卡片跳动。
126
+ - **AI 优化缓存**:客户端按「工作区 + 标题 + 正文」缓存本页内的优化结果;宿主进程内也保留一份同样键的缓存(最多 64 条 / 约 200 万字符)。相同且未改动的标题 + 正文再次优化时直接复用上次结果,不再调用大模型;标题或正文变化后会重新请求。
127
+ - 窗口重新获得焦点(或页面重新可见)时,如果本地没有未保存的修改,会自动读取磁盘上的最新内容;刷新前会立即记录列表的 `scrollTop`,写入新内容后恢复,读取时也保留当前卡片顺序,不会让正在看的卡片或列表位置跳动。
122
128
 
123
129
  ## 实现
124
130
 
125
- - **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` 通过 `agentDefaultModel.currentSelection()` + `llm.prepareCall()` 调用当前默认模型,流式拼接文本后返回。
126
- - **Client**(`client.js`):整个 bundle 包在 IIFE 里;通过 `shell.overlay` 注册浮窗,读取 `uiSession.adapter.current` 得到当前会话与工作区。标题、序号、拖拽排序和合并都在本地分段数组上完成,数组顺序就是保存顺序。侧边栏角标用 `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()`。图片在插入 / 发送时以 Markdown 图片语法追加,聊天记录里的 DSH 渲染器会按工作区 cwd 解析这些路径。
127
- - AI 优化只改分段文字,不动标题和图片;单段与批量优化都支持撤销。没有默认模型或模型调用失败时只提示错误,不影响笔记读写。
131
+ - **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()` 调用当前默认模型,流式拼接文本后写入缓存并返回。
132
+ - **Client**(`client.js`):整个 bundle 包在 IIFE 里;通过 `shell.overlay` 注册浮窗,读取 `uiSession.adapter.current` 得到当前会话与工作区。标题、序号、拖拽排序和合并都在本地分段数组上完成,数组顺序就是保存顺序;空标题用占位按钮渲染,点击后才挂载输入框,`↑` / `↓` 在首 / 末行时把焦点和光标移到相邻卡片。侧边栏角标用 `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()`。图片在插入 / 发送时以 Markdown 图片语法追加,聊天记录里的 DSH 渲染器会按工作区 cwd 解析这些路径。
133
+ - AI 优化只改分段文字,不动标题和图片;单段优化后该卡片按钮变为「撤销优化」,批量优化后工具栏按钮变为「撤销优化」,撤销会恢复优化前的文字。优化结果按内容缓存在客户端与宿主两侧,相同内容再次优化不会重复请求;没有默认模型或模型调用失败时只提示错误,不影响笔记读写。
134
+ - 浮窗坐标同时受视口和宿主标题栏约束:拖动、调整尺寸、窗口缩放和快捷键复位都走同一个 `clampUi()`,最小 `top` 取 `--dsh-frame-chrome-top`(全屏时为 0)/ `--dsh-frame-top-clearance` 再加 8px。
128
135
  - 文案通过宿主 `locale` 服务注册中英文;找不到 locale 时按浏览器语言回退。
129
136
 
130
137
  ## 开发
@@ -148,14 +155,14 @@ node test/package.test.mjs
148
155
  npm test
149
156
  ```
150
157
 
151
- `test/client-smoke.mjs` 会真正执行 `apply(ctx)` 并渲染浮窗,覆盖:分段加载、编辑自动保存、新分段置顶、标题自动保存、序号选中、合并、单段 / 批量 AI 优化、拖拽排序、未发送角标、侧边栏工作区角标、回车拆分、单段/多段插入与替换、单段/多段发送与自动归档、归档显示隐藏、归档恢复与删除(含撤销)、全选、折叠、最小化 / 恢复、拖动调整尺寸、全局快捷键、深色模式与字体 CSS、重复执行 bundle 的兼容性,以及粘贴 / 选择图片、图片芯片、删除芯片时清理磁盘文件与带图插入发送。
158
+ `test/client-smoke.mjs` 会真正执行 `apply(ctx)` 并渲染浮窗,覆盖:分段加载、编辑自动保存、自动刷新保持列表滚动位置、新分段置顶、空标题占位与点击后出现输入框、标题自动保存、序号选中、方向键跨分段切换光标、合并、单段 / 选中分段 AI 优化、优化后按钮切换为撤销、相同内容命中缓存、拖拽排序、未发送角标、侧边栏工作区角标、回车拆分、单段/多段插入与替换、单段/多段发送与自动归档、归档显示隐藏、归档恢复与删除(含撤销)、全选、折叠、最小化 / 恢复、拖动调整尺寸、标题栏下方位置限制、全局快捷键与复位快捷键、深色模式与字体 CSS、重复执行 bundle 的兼容性,以及粘贴 / 选择图片、图片芯片、删除芯片时清理磁盘文件与带图插入发送。
152
159
 
153
160
  ## 已知限制
154
161
 
155
162
  - 浮窗不会自动把内容推给 Agent;发送使用当前会话的排队发送,需要 Agent 可接收消息。
156
163
  - 发送按钮依赖 DSH 的会话接口;接口不可用时降级为插入输入框并提示。
157
164
  - 图片只支持 PNG / JPEG / WebP / GIF,单张最大 20 MB、每段最多 20 张;其它附件类型暂不支持。
158
- - AI 优化需要 DSH 里已配置默认模型;未配置时按钮会提示不可用,记笔记本身不受影响。
165
+ - AI 优化需要 DSH 里已配置默认模型;未配置时按钮会提示不可用,记笔记本身不受影响。优化缓存保存在宿主进程内,重启 DSH 或切换默认模型后,相同内容会再请求一次。
159
166
  - 删除图片芯片会删除插件目录里的文件;如果同一路径仍被其它分段引用,则保留文件。选择的原路径图片只删引用,不删除原图。
160
167
  - 网页版没有 `__DSH_HOST_PATHS__` 桥,选择 / 拖入的图片会像粘贴一样先复制到 `.dsh/notepad/images/`。
161
168
  - 图片在浮窗里用缩略芯片表示,不提供富文本行内图文混排;插入 / 发送后由聊天记录按 Markdown 渲染。
package/client.js CHANGED
@@ -37,7 +37,7 @@ window.__ModuleLoader__.load({
37
37
  factory: (require) => {
38
38
  const React = require('react');
39
39
  const h = React.createElement;
40
- const { useState, useEffect, useRef } = React;
40
+ const { useState, useEffect, useLayoutEffect, useRef } = React;
41
41
 
42
42
  const NS = 'dsh-notepad';
43
43
  const API = '/dsh-notepad/api';
@@ -56,6 +56,8 @@ window.__ModuleLoader__.load({
56
56
  const MAX_OPTIMIZE_CHARS = 60000;
57
57
  const SIDEBAR_REFRESH_MS = 20000;
58
58
  const IMAGE_DELETE_DELAY = 5000;
59
+ const OPTIMIZE_CACHE_LIMIT = 100;
60
+ const EDGE_GAP = 8;
59
61
  const IMAGE_MIME = {
60
62
  'image/png': 'png',
61
63
  'image/jpeg': 'jpg',
@@ -114,11 +116,14 @@ window.__ModuleLoader__.load({
114
116
  mergeHint: '至少选择 2 段才能合并',
115
117
  mergedToast: '已合并 {n} 段',
116
118
  optimizeSegment: 'AI 优化此段',
117
- optimizeAll: 'AI 优化全部分段',
119
+ optimizeSelected: 'AI 优化选中的 {n} 段',
120
+ optimizeSelectHint: '勾选分段后可批量优化',
118
121
  optimizeAllProgress: 'AI 优化中 {done}/{total}',
119
122
  optimizing: '优化中…',
123
+ undoOptimize: '撤销优化',
120
124
  optimizedToast: '已优化此段',
121
125
  optimizedCount: '已优化 {n} 段',
126
+ optimizeUndone: '已撤销优化',
122
127
  optimizeEmpty: '没有可优化的文字',
123
128
  optimizeTooLong: '内容太长,无法优化',
124
129
  optimizeUnavailable: 'AI 服务不可用',
@@ -155,9 +160,10 @@ window.__ModuleLoader__.load({
155
160
  show: '显示记事本(Ctrl+Shift+N)',
156
161
  chars: '{n} 字符',
157
162
  segments: '{n} 段',
158
- hint: 'Enter 新建分段 · Shift+Enter 换行 · Ctrl+Enter 插入 · 支持粘贴图片',
163
+ hint: 'Enter 新建分段 · Shift+Enter 换行 · Ctrl+Enter 插入 · Ctrl+Shift+0 复位浮窗 · 支持粘贴图片',
159
164
  workspace: '工作区',
160
165
  resize: '拖动调整大小',
166
+ uiReset: '已复位浮窗位置和尺寸',
161
167
  addImage: '添加图片',
162
168
  removeImage: '移除图片',
163
169
  openImage: '打开图片',
@@ -218,11 +224,14 @@ window.__ModuleLoader__.load({
218
224
  mergeHint: 'Select at least 2 segments to merge',
219
225
  mergedToast: 'Merged {n} segments',
220
226
  optimizeSegment: 'Improve this segment with AI',
221
- optimizeAll: 'Improve all segments with AI',
227
+ optimizeSelected: 'Improve {n} selected with AI',
228
+ optimizeSelectHint: 'Select segments to improve',
222
229
  optimizeAllProgress: 'Improving {done}/{total}',
223
230
  optimizing: 'Improving…',
231
+ undoOptimize: 'Undo improvement',
224
232
  optimizedToast: 'Segment improved',
225
233
  optimizedCount: 'Improved {n} segments',
234
+ optimizeUndone: 'Improvement undone',
226
235
  optimizeEmpty: 'Nothing to improve',
227
236
  optimizeTooLong: 'Too long to improve',
228
237
  optimizeUnavailable: 'AI service unavailable',
@@ -259,9 +268,10 @@ window.__ModuleLoader__.load({
259
268
  show: 'Show notepad (Ctrl+Shift+N)',
260
269
  chars: '{n} chars',
261
270
  segments: '{n} segments',
262
- hint: 'Enter new segment · Shift+Enter newline · Ctrl+Enter insert · Paste images',
271
+ hint: 'Enter new segment · Shift+Enter newline · Ctrl+Enter insert · Ctrl+Shift+0 reset window · Paste images',
263
272
  workspace: 'Workspace',
264
273
  resize: 'Drag to resize',
274
+ uiReset: 'Window position and size reset',
265
275
  addImage: 'Add image',
266
276
  removeImage: 'Remove image',
267
277
  openImage: 'Open image',
@@ -548,6 +558,24 @@ window.__ModuleLoader__.load({
548
558
  );
549
559
  }
550
560
 
561
+ /** Curved arrow: undo the last AI improvement. */
562
+ function UndoIcon({ size = 16 }) {
563
+ return h(
564
+ Svg,
565
+ { size, viewBox: '0 0 16 16' },
566
+ h('path', {
567
+ d: 'M5.4 4.2 2.8 6.8l2.6 2.6',
568
+ stroke: 'currentColor', strokeWidth: 1.35,
569
+ strokeLinecap: 'round', strokeLinejoin: 'round',
570
+ }),
571
+ h('path', {
572
+ d: 'M3 6.8h5.3c2.7 0 4.9 2.2 4.9 4.9',
573
+ stroke: 'currentColor', strokeWidth: 1.35,
574
+ strokeLinecap: 'round', strokeLinejoin: 'round',
575
+ }),
576
+ );
577
+ }
578
+
551
579
  /** Two arrows converging: merge selected segments into one. */
552
580
  function MergeIcon({ size = 16 }) {
553
581
  return h(
@@ -608,7 +636,7 @@ window.__ModuleLoader__.load({
608
636
  .dsh-np-btn[data-accent=true]{background:var(--np-accent);color:var(--np-accent-fg)}
609
637
  .dsh-np-btn[data-accent=true]:hover:not(:disabled){background:color-mix(in srgb,var(--np-accent) 88%,#000);color:var(--np-accent-fg)}
610
638
  .dsh-np-btn:disabled{opacity:.38;cursor:default;background:transparent;color:var(--dsw-alias-label-tertiary,#6b7280)}
611
- .dsh-np-btn:focus-visible,.dsh-np-segbtn:focus-visible,.dsh-np-link:focus-visible,.dsh-np-empty-add:focus-visible,.dsh-np-toast-action:focus-visible,.dsh-np-selectall input:focus-visible,.dsh-np-check input:focus-visible,.dsh-np-restore:focus-visible,.dsh-np-img-open:focus-visible,.dsh-np-img-remove:focus-visible{outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#4176e6));outline-offset:-2px}
639
+ .dsh-np-btn:focus-visible,.dsh-np-segbtn:focus-visible,.dsh-np-link:focus-visible,.dsh-np-empty-add:focus-visible,.dsh-np-toast-action:focus-visible,.dsh-np-selectall input:focus-visible,.dsh-np-check input:focus-visible,.dsh-np-title-add:focus-visible,.dsh-np-restore:focus-visible,.dsh-np-img-open:focus-visible,.dsh-np-img-remove:focus-visible{outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#4176e6));outline-offset:-2px}
612
640
  .dsh-np-body{display:flex;flex:1 1 auto;flex-direction:column;min-height:0;padding:8px 10px 0;opacity:1;transition:opacity .16s ease,visibility 0s linear 0s}
613
641
  .dsh-np-card[data-minimized=true] .dsh-np-body{opacity:0;visibility:hidden;pointer-events:none;transition:opacity .16s ease,visibility 0s linear .16s}
614
642
  .dsh-np-tools{display:flex;align-items:center;flex-wrap:wrap;gap:4px;flex:none;padding:0 2px 7px;border-bottom:1px solid var(--dsw-alias-border-l1,rgb(15 23 42/.08))}
@@ -633,6 +661,7 @@ window.__ModuleLoader__.load({
633
661
  .dsh-np-title-input::placeholder{color:var(--dsw-alias-label-caption,#9aa3b2);font-weight:400}
634
662
  .dsh-np-title-input:hover{background:var(--dsw-alias-interactive-bg-hover,rgb(38 49 72/.06))}
635
663
  .dsh-np-title-input:focus{background:var(--np-surface);box-shadow:0 0 0 1px var(--np-accent) inset;color:var(--dsw-alias-label-primary,#1f2430)}
664
+ .dsh-np-title-add{flex:1 1 auto;min-width:0;width:0;height:20px;padding:0 4px;border:0;border-radius:5px;background:transparent;color:var(--dsw-alias-label-caption,#9aa3b2);font:inherit;font-size:11px;font-weight:400;line-height:20px;text-align:left;cursor:text;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:.75}
636
665
  .dsh-np-seg-title{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary,#4b5563);font-size:11px;font-weight:600}
637
666
  .dsh-np-archived-chip{flex:none;padding:0 6px;border-radius:5px;background:var(--dsw-alias-interactive-bg-hover,rgb(38 49 72/.06));color:var(--dsw-alias-label-tertiary,#6b7280);font-size:10px;line-height:16px}
638
667
  .dsh-np-segbtn{flex:none;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary,#6b7280);cursor:pointer;transition:background .12s,color .12s,opacity .12s;opacity:.8}
@@ -1032,6 +1061,43 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1032
1061
  return parts.length === 0 ? String(root ?? '') : parts[parts.length - 1];
1033
1062
  }
1034
1063
 
1064
+ /**
1065
+ * Height of the chrome the host draws over the page (the Windows title bar
1066
+ * with maximize / minimize / close, or the macOS traffic lights). The
1067
+ * floating card must never move above that strip. The host exposes it as a
1068
+ * CSS variable on <html>; standalone browsers have none, so this is 0.
1069
+ * @returns {number}
1070
+ */
1071
+ function hostChromeTop() {
1072
+ try {
1073
+ const root = typeof document !== 'undefined' ? document.documentElement : null;
1074
+ if (root === null || root === undefined) return 0;
1075
+ const hasAttribute = (name) => typeof root.hasAttribute === 'function' && root.hasAttribute(name);
1076
+ const readVar = (name) => {
1077
+ try {
1078
+ if (typeof window === 'undefined' || typeof window.getComputedStyle !== 'function') return NaN;
1079
+ const style = window.getComputedStyle(root);
1080
+ return Number.parseFloat(style?.getPropertyValue(name) ?? '');
1081
+ } catch {
1082
+ return NaN;
1083
+ }
1084
+ };
1085
+ // `--dsh-frame-chrome-top` is 0 while the host is in fullscreen.
1086
+ const chrome = readVar('--dsh-frame-chrome-top');
1087
+ if (Number.isFinite(chrome)) return Math.max(0, chrome);
1088
+ const clearance = readVar('--dsh-frame-top-clearance');
1089
+ if (Number.isFinite(clearance) && clearance > 0) return clearance;
1090
+ if (hasAttribute('data-windows-titlebar')) {
1091
+ const titlebar = readVar('--dsh-windows-titlebar-height');
1092
+ return Number.isFinite(titlebar) && titlebar > 0 ? titlebar : 32;
1093
+ }
1094
+ const platform = typeof root.getAttribute === 'function' ? root.getAttribute('data-platform') : '';
1095
+ return platform === 'darwin' ? 48 : 0;
1096
+ } catch {
1097
+ return 0;
1098
+ }
1099
+ }
1100
+
1035
1101
  function readStoredUi() {
1036
1102
  const base = {
1037
1103
  x: null,
@@ -1064,17 +1130,18 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1064
1130
  function clampUi(ui) {
1065
1131
  const vw = (typeof window !== 'undefined' && window.innerWidth) || 1280;
1066
1132
  const vh = (typeof window !== 'undefined' && window.innerHeight) || 800;
1067
- const widthLimit = Math.max(240, vw - 16);
1068
- const heightLimit = Math.max(180, vh - 16);
1133
+ const minTop = hostChromeTop() + EDGE_GAP;
1134
+ const widthLimit = Math.max(240, vw - EDGE_GAP * 2);
1135
+ const heightLimit = Math.max(180, vh - minTop - EDGE_GAP);
1069
1136
  const width = Math.min(Math.max(MIN_W, Number(ui.width) || DEFAULT_W), widthLimit);
1070
1137
  const height = Math.min(Math.max(MIN_H, Number(ui.height) || DEFAULT_H), heightLimit);
1071
1138
  const displayHeight = ui.minimized ? HEAD_H : height;
1072
1139
  const x = ui.x === null || ui.x === undefined
1073
- ? vw - width - 24
1074
- : Math.min(Math.max(8, ui.x), Math.max(8, vw - width - 8));
1140
+ ? Math.max(EDGE_GAP, vw - width - 24)
1141
+ : Math.min(Math.max(EDGE_GAP, ui.x), Math.max(EDGE_GAP, vw - width - EDGE_GAP));
1075
1142
  const y = ui.y === null || ui.y === undefined
1076
- ? vh - displayHeight - 24
1077
- : Math.min(Math.max(8, ui.y), Math.max(8, vh - displayHeight - 8));
1143
+ ? Math.max(minTop, vh - displayHeight - 24)
1144
+ : Math.min(Math.max(minTop, ui.y), Math.max(minTop, vh - displayHeight - EDGE_GAP));
1078
1145
  return { ...ui, x, y, width, height };
1079
1146
  }
1080
1147
 
@@ -1201,7 +1268,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1201
1268
  const [error, setError] = useState('');
1202
1269
  const [savedAt, setSavedAt] = useState(null);
1203
1270
  const [toast, setToast] = useState(null);
1204
- const [ui, setUi] = useState(readStoredUi);
1271
+ const [ui, setUi] = useState(() => clampUi(readStoredUi()));
1205
1272
  const [sending, setSending] = useState(false);
1206
1273
  const [resizing, setResizing] = useState(false);
1207
1274
  const [uploading, setUploading] = useState({});
@@ -1209,7 +1276,10 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1209
1276
  const [reorderDrag, setReorderDrag] = useState('');
1210
1277
  const [reorderOver, setReorderOver] = useState(null);
1211
1278
  const [optimizing, setOptimizing] = useState(() => new Set());
1212
- const [optimizeAll, setOptimizeAll] = useState(null);
1279
+ const [optimizeBatch, setOptimizeBatch] = useState(null);
1280
+ const [segmentUndo, setSegmentUndo] = useState(() => new Map());
1281
+ const [batchUndo, setBatchUndo] = useState(null);
1282
+ const [titleEditing, setTitleEditing] = useState('');
1213
1283
  const [wsCounts, setWsCounts] = useState({});
1214
1284
  const [currentWsId, setCurrentWsId] = useState('');
1215
1285
 
@@ -1235,9 +1305,13 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1235
1305
  const fileInputRefs = useRef(new Map());
1236
1306
  const listRef = useRef(null);
1237
1307
  const pendingFocusRef = useRef(null);
1308
+ const pendingScrollRef = useRef(null);
1309
+ const refreshSeqRef = useRef(0);
1238
1310
  const undoRef = useRef(null);
1239
1311
  const optimizingRef = useRef(new Set());
1240
- const optimizeAllRef = useRef(false);
1312
+ const optimizeBatchRef = useRef(false);
1313
+ const optimizeCacheRef = useRef(new Map());
1314
+ const batchUndoRef = useRef(null);
1241
1315
  const reorderDragRef = useRef('');
1242
1316
  const reorderOverRef = useRef(null);
1243
1317
  const imageDeleteTimersRef = useRef(new Map());
@@ -1259,6 +1333,17 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1259
1333
  if (changed) setSelection(pruned);
1260
1334
  };
1261
1335
 
1336
+ /** Keep the ref and the state in sync for the batch-optimize undo slot. */
1337
+ const setBatchUndoState = (value) => {
1338
+ batchUndoRef.current = value;
1339
+ setBatchUndo(value);
1340
+ };
1341
+
1342
+ /** Any local edit invalidates the "undo the last batch" offer. */
1343
+ const invalidateBatchUndo = () => {
1344
+ if (batchUndoRef.current !== null) setBatchUndoState(null);
1345
+ };
1346
+
1262
1347
  // Session + workspace -------------------------------------------------
1263
1348
  useEffect(() => {
1264
1349
  let disposed = false;
@@ -1378,6 +1463,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1378
1463
  saveNowRef.current = saveNow;
1379
1464
 
1380
1465
  const commitSegments = (next, options = {}) => {
1466
+ invalidateBatchUndo();
1381
1467
  segmentsRef.current = next;
1382
1468
  setSegments(next);
1383
1469
  pruneSelectionFor(next);
@@ -1396,6 +1482,10 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1396
1482
  segmentsRef.current = [];
1397
1483
  setSegments([]);
1398
1484
  setSelection(new Set());
1485
+ setSegmentUndo(new Map());
1486
+ setBatchUndoState(null);
1487
+ setTitleEditing('');
1488
+ pendingScrollRef.current = null;
1399
1489
  setStatus('idle');
1400
1490
  setError('');
1401
1491
  return undefined;
@@ -1412,6 +1502,10 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1412
1502
  segmentsRef.current = [];
1413
1503
  setSegments([]);
1414
1504
  setSelection(new Set());
1505
+ setSegmentUndo(new Map());
1506
+ setBatchUndoState(null);
1507
+ setTitleEditing('');
1508
+ pendingScrollRef.current = null;
1415
1509
  setStatus('loading');
1416
1510
  setError('');
1417
1511
  const load = () => {
@@ -1430,6 +1524,8 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1430
1524
  segmentsRef.current = value;
1431
1525
  setSegments(value);
1432
1526
  setSelection(new Set());
1527
+ setSegmentUndo(new Map());
1528
+ setBatchUndoState(null);
1433
1529
  versionRef.current = typeof data.mtimeMs === 'number' ? data.mtimeMs : null;
1434
1530
  setSavedAt(typeof data.mtimeMs === 'number' ? data.mtimeMs : null);
1435
1531
  dirtyRef.current = false;
@@ -1566,14 +1662,41 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1566
1662
  autoGrow(element);
1567
1663
  }, [segments]);
1568
1664
 
1665
+ // Restore a captured scroll offset right after a refresh commits ------
1666
+ useLayoutEffect(() => {
1667
+ const pending = pendingScrollRef.current;
1668
+ if (pending === null || pending === undefined) return;
1669
+ pendingScrollRef.current = null;
1670
+ const list = listRef.current;
1671
+ if (list === null || list === undefined) return;
1672
+ try {
1673
+ list.scrollTop = pending;
1674
+ } catch {
1675
+ /* A detached list cannot be scrolled. */
1676
+ }
1677
+ }, [segments]);
1678
+
1569
1679
  // Keyboard ------------------------------------------------------------
1570
1680
  useEffect(() => {
1571
1681
  const onKeyDown = (event) => {
1572
1682
  const chord = event.ctrlKey || event.metaKey;
1573
- if (!chord || !event.shiftKey) return;
1574
- if (event.key !== 'N' && event.key !== 'n') return;
1575
- event.preventDefault();
1576
- setUi((previous) => clampUi({ ...previous, hidden: !previous.hidden }));
1683
+ if (!chord || !event.shiftKey || event.altKey) return;
1684
+ if (event.key === 'N' || event.key === 'n') {
1685
+ event.preventDefault();
1686
+ setUi((previous) => clampUi({ ...previous, hidden: !previous.hidden }));
1687
+ return;
1688
+ }
1689
+ if (event.key === '0' || event.code === 'Digit0' || event.code === 'Numpad0') {
1690
+ event.preventDefault();
1691
+ setUi((previous) => clampUi({
1692
+ ...previous,
1693
+ x: null,
1694
+ y: null,
1695
+ width: DEFAULT_W,
1696
+ height: DEFAULT_H,
1697
+ }));
1698
+ showToast(t('uiReset'));
1699
+ }
1577
1700
  };
1578
1701
  window.addEventListener('keydown', onKeyDown, true);
1579
1702
  return () => {
@@ -1616,10 +1739,25 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1616
1739
  const active = typeof document !== 'undefined' ? document.activeElement : null;
1617
1740
  if (active !== null && active !== undefined && typeof active.closest === 'function'
1618
1741
  && active.closest('[data-dsh-notepad="true"]') !== null) return;
1742
+ // Snapshot the scroll offset right before the refresh; the list may
1743
+ // re-render (and jump) while the updated note is applied.
1744
+ const list = listRef.current;
1745
+ const scrollTop = list !== null && list !== undefined && Number.isFinite(list.scrollTop)
1746
+ ? list.scrollTop
1747
+ : null;
1748
+ const mySeq = ++refreshSeqRef.current;
1749
+ pendingScrollRef.current = scrollTop;
1619
1750
  fetchNote(root, sessionId)
1620
1751
  .then((data) => {
1621
- if (rootRef.current !== root || dirtyRef.current) return;
1622
- if (typeof data.mtimeMs === 'number' && data.mtimeMs === versionRef.current) return;
1752
+ if (mySeq !== refreshSeqRef.current) return;
1753
+ if (rootRef.current !== root || dirtyRef.current) {
1754
+ pendingScrollRef.current = null;
1755
+ return;
1756
+ }
1757
+ if (typeof data.mtimeMs === 'number' && data.mtimeMs === versionRef.current) {
1758
+ pendingScrollRef.current = null;
1759
+ return;
1760
+ }
1623
1761
  let incoming = normalizeServerSegments(data);
1624
1762
  if (isLegacyPayload(data)) incoming = incoming.slice().reverse();
1625
1763
  if (incoming.length === 0) incoming = [newSegment('')];
@@ -1627,11 +1765,15 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1627
1765
  segmentsRef.current = value;
1628
1766
  setSegments(value);
1629
1767
  pruneSelectionFor(value);
1768
+ setSegmentUndo(new Map());
1769
+ setBatchUndoState(null);
1630
1770
  versionRef.current = typeof data.mtimeMs === 'number' ? data.mtimeMs : null;
1631
1771
  setSavedAt(typeof data.mtimeMs === 'number' ? data.mtimeMs : null);
1632
1772
  setStatus('saved');
1633
1773
  })
1634
- .catch(() => {});
1774
+ .catch(() => {
1775
+ if (mySeq === refreshSeqRef.current) pendingScrollRef.current = null;
1776
+ });
1635
1777
  };
1636
1778
  const onVisibility = () => {
1637
1779
  if (typeof document !== 'undefined' && document.visibilityState === 'visible') refresh();
@@ -1650,9 +1792,17 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1650
1792
 
1651
1793
  // Segment mutations ---------------------------------------------------
1652
1794
  const updateSegmentText = (id, value) => {
1795
+ invalidateBatchUndo();
1653
1796
  const next = segmentsRef.current.map((segment) => (segment.id === id ? { ...segment, text: value } : segment));
1654
1797
  segmentsRef.current = next;
1655
1798
  setSegments(next);
1799
+ setSegmentUndo((previous) => {
1800
+ const record = previous.get(id);
1801
+ if (record === undefined || record.after === value) return previous;
1802
+ const pruned = new Map(previous);
1803
+ pruned.delete(id);
1804
+ return pruned;
1805
+ });
1656
1806
  dirtyRef.current = true;
1657
1807
  setStatus('dirty');
1658
1808
  setError('');
@@ -1663,6 +1813,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1663
1813
  const title = String(value ?? '').slice(0, MAX_TITLE_CHARS);
1664
1814
  const target = segmentsRef.current.find((segment) => segment.id === id);
1665
1815
  if (target === undefined || target.title === title) return;
1816
+ invalidateBatchUndo();
1666
1817
  const next = segmentsRef.current.map((segment) => (segment.id === id ? { ...segment, title } : segment));
1667
1818
  segmentsRef.current = next;
1668
1819
  setSegments(next);
@@ -1935,6 +2086,8 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1935
2086
  if (record === null || record === undefined) return;
1936
2087
  undoRef.current = null;
1937
2088
  clearPendingImageDeletes();
2089
+ setSegmentUndo(new Map());
2090
+ setBatchUndoState(null);
1938
2091
  commitSegments(record.snapshot);
1939
2092
  setToast(null);
1940
2093
  };
@@ -2084,14 +2237,33 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2084
2237
  setOptimizing(next);
2085
2238
  };
2086
2239
 
2240
+ /** Cache key: the prompt depends on the workspace, title and card text. */
2241
+ const optimizationCacheKey = (root, segment) =>
2242
+ `${root}\u0000${segment.title ?? ''}\u0000${segment.text}`;
2243
+
2244
+ const rememberOptimization = (key, value) => {
2245
+ const cache = optimizeCacheRef.current;
2246
+ cache.delete(key);
2247
+ cache.set(key, value);
2248
+ while (cache.size > OPTIMIZE_CACHE_LIMIT) {
2249
+ const oldest = cache.keys().next();
2250
+ if (oldest.done === true) break;
2251
+ cache.delete(oldest.value);
2252
+ }
2253
+ };
2254
+
2087
2255
  const requestOptimization = async (segment) => {
2088
2256
  const root = rootRef.current;
2089
2257
  const sessionId = rootSessionRef.current;
2090
2258
  if (root === '' || sessionId === '') throw new Error(t('optimizeUnavailable'));
2091
2259
  if (segment.text.length > MAX_OPTIMIZE_CHARS) throw new Error(t('optimizeTooLong'));
2260
+ const cacheKey = optimizationCacheKey(root, segment);
2261
+ const cached = optimizeCacheRef.current.get(cacheKey);
2262
+ if (typeof cached === 'string' && cached !== '') return cached;
2092
2263
  const data = await optimizeNoteText(root, sessionId, segment.text, segment.title ?? '');
2093
2264
  const value = typeof data.text === 'string' ? data.text.trim() : '';
2094
2265
  if (value === '') throw new Error(t('optimizeFailed'));
2266
+ rememberOptimization(cacheKey, value);
2095
2267
  return value;
2096
2268
  };
2097
2269
 
@@ -2102,18 +2274,21 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2102
2274
  showToast(t('optimizeEmpty'));
2103
2275
  return;
2104
2276
  }
2105
- const snapshot = segmentsRef.current;
2106
2277
  const original = target.text;
2107
2278
  markOptimizing(id, true);
2108
2279
  try {
2109
2280
  const value = await requestOptimization(target);
2110
2281
  const current = segmentsRef.current.find((segment) => segment.id === id);
2111
2282
  if (current === undefined || current.text !== original || value === original) return;
2112
- undoRef.current = { snapshot };
2113
2283
  const next = segmentsRef.current.map((segment) =>
2114
2284
  (segment.id === id ? { ...segment, text: value } : segment));
2115
2285
  commitSegments(next);
2116
- showToast(t('optimizedToast'), { label: t('undo'), onClick: undoLast });
2286
+ setSegmentUndo((previous) => {
2287
+ const updated = new Map(previous);
2288
+ updated.set(id, { before: original, after: value });
2289
+ return updated;
2290
+ });
2291
+ showToast(t('optimizedToast'));
2117
2292
  } catch (reason) {
2118
2293
  const message = String(reason?.message ?? reason);
2119
2294
  showToast(message === t('optimizeTooLong') || message === t('optimizeUnavailable')
@@ -2124,20 +2299,46 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2124
2299
  }
2125
2300
  };
2126
2301
 
2127
- const optimizeAllSegments = async () => {
2128
- if (optimizeAllRef.current) return;
2302
+ const undoOptimizeSegment = (id) => {
2303
+ const record = segmentUndo.get(id);
2304
+ const current = segmentsRef.current.find((segment) => segment.id === id);
2305
+ if (record !== undefined && current !== undefined && current.text === record.after) {
2306
+ const next = segmentsRef.current.map((segment) =>
2307
+ (segment.id === id ? { ...segment, text: record.before } : segment));
2308
+ commitSegments(next);
2309
+ setSegmentUndo((previous) => {
2310
+ if (!previous.has(id)) return previous;
2311
+ const updated = new Map(previous);
2312
+ updated.delete(id);
2313
+ return updated;
2314
+ });
2315
+ showToast(t('optimizeUndone'));
2316
+ return;
2317
+ }
2318
+ setSegmentUndo((previous) => {
2319
+ if (!previous.has(id)) return previous;
2320
+ const updated = new Map(previous);
2321
+ updated.delete(id);
2322
+ return updated;
2323
+ });
2324
+ };
2325
+
2326
+ /** Improve only the checked segments; identical text is answered from cache. */
2327
+ const optimizeSelectedSegments = async () => {
2328
+ if (optimizeBatchRef.current) return;
2329
+ const ids = new Set(selectedRef.current);
2129
2330
  const targets = segmentsRef.current.filter((segment) =>
2130
- segment.archived !== true && segment.text.trim() !== '');
2331
+ segment.archived !== true && ids.has(segment.id) && segment.text.trim() !== '');
2131
2332
  if (targets.length === 0) {
2132
2333
  showToast(t('optimizeEmpty'));
2133
2334
  return;
2134
2335
  }
2135
- optimizeAllRef.current = true;
2336
+ optimizeBatchRef.current = true;
2136
2337
  const snapshot = segmentsRef.current;
2137
2338
  let done = 0;
2138
2339
  let changed = 0;
2139
2340
  let failed = 0;
2140
- setOptimizeAll({ done, total: targets.length });
2341
+ setOptimizeBatch({ done, total: targets.length });
2141
2342
  try {
2142
2343
  for (const target of targets) {
2143
2344
  const current = segmentsRef.current.find((segment) =>
@@ -2152,6 +2353,11 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2152
2353
  ? { ...segment, text: value }
2153
2354
  : segment));
2154
2355
  commitSegments(next);
2356
+ setSegmentUndo((previous) => {
2357
+ const updated = new Map(previous);
2358
+ updated.set(target.id, { before: current.text, after: value });
2359
+ return updated;
2360
+ });
2155
2361
  changed += 1;
2156
2362
  }
2157
2363
  } catch {
@@ -2161,20 +2367,29 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2161
2367
  }
2162
2368
  }
2163
2369
  done += 1;
2164
- setOptimizeAll({ done, total: targets.length });
2370
+ setOptimizeBatch({ done, total: targets.length });
2165
2371
  }
2166
2372
  if (changed > 0) {
2167
- undoRef.current = { snapshot };
2168
- showToast(t('optimizedCount', { n: changed }), { label: t('undo'), onClick: undoLast });
2373
+ setBatchUndoState({ snapshot });
2374
+ showToast(t('optimizedCount', { n: changed }));
2169
2375
  } else {
2170
2376
  showToast(failed > 0 ? t('optimizeFailed') : t('optimizedToast'));
2171
2377
  }
2172
2378
  } finally {
2173
- optimizeAllRef.current = false;
2174
- setOptimizeAll(null);
2379
+ optimizeBatchRef.current = false;
2380
+ setOptimizeBatch(null);
2175
2381
  }
2176
2382
  };
2177
2383
 
2384
+ const undoBatchOptimization = () => {
2385
+ const record = batchUndoRef.current;
2386
+ if (record === null || record === undefined) return;
2387
+ setBatchUndoState(null);
2388
+ setSegmentUndo(new Map());
2389
+ commitSegments(record.snapshot);
2390
+ showToast(t('optimizeUndone'));
2391
+ };
2392
+
2178
2393
  // Selection -----------------------------------------------------------
2179
2394
  const toggleSelected = (id) => {
2180
2395
  const next = new Set(selectedRef.current);
@@ -2370,6 +2585,34 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2370
2585
  updateSegmentText(id, event.target?.value ?? '');
2371
2586
  };
2372
2587
 
2588
+ /** Focus a card's textarea and place the caret at an absolute offset. */
2589
+ const focusSegmentAt = (id, caret) => {
2590
+ const element = textareaRefs.current.get(id);
2591
+ if (element === null || element === undefined) return false;
2592
+ try {
2593
+ element.focus?.({ preventScroll: true });
2594
+ } catch {
2595
+ try {
2596
+ element.focus?.();
2597
+ } catch {
2598
+ /* ignore */
2599
+ }
2600
+ }
2601
+ try {
2602
+ const value = typeof element.value === 'string' ? element.value : '';
2603
+ const position = Math.max(0, Math.min(Number(caret) || 0, value.length));
2604
+ if (typeof element.setSelectionRange === 'function') element.setSelectionRange(position, position);
2605
+ } catch {
2606
+ /* A non-layout shim may not support ranges. */
2607
+ }
2608
+ try {
2609
+ element.scrollIntoView?.({ block: 'nearest' });
2610
+ } catch {
2611
+ /* ignore */
2612
+ }
2613
+ return true;
2614
+ };
2615
+
2373
2616
  const onSegmentKeyDown = (event, id) => {
2374
2617
  if (event.isComposing === true || event.nativeEvent?.isComposing === true) return;
2375
2618
  const chord = event.ctrlKey || event.metaKey;
@@ -2384,6 +2627,28 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2384
2627
  insertSegments(list);
2385
2628
  return;
2386
2629
  }
2630
+ if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
2631
+ if (event.altKey || event.shiftKey || chord) return;
2632
+ const element = event.currentTarget ?? event.target;
2633
+ const value = typeof element?.value === 'string' ? element.value : '';
2634
+ const start = typeof element?.selectionStart === 'number' ? element.selectionStart : null;
2635
+ const end = typeof element?.selectionEnd === 'number' ? element.selectionEnd : start;
2636
+ if (start === null || start !== end) return;
2637
+ const firstBreak = value.indexOf('\n');
2638
+ const lastBreak = value.lastIndexOf('\n');
2639
+ const up = event.key === 'ArrowUp';
2640
+ const atEdge = up
2641
+ ? (firstBreak === -1 || start <= firstBreak)
2642
+ : (lastBreak === -1 || start > lastBreak);
2643
+ if (!atEdge) return;
2644
+ const active = segmentsRef.current.filter((segment) => segment.archived !== true);
2645
+ const position = active.findIndex((segment) => segment.id === id);
2646
+ const neighbor = position < 0 ? undefined : active[position + (up ? -1 : 1)];
2647
+ if (neighbor !== undefined && focusSegmentAt(neighbor.id, up ? neighbor.text.length : 0)) {
2648
+ event.preventDefault?.();
2649
+ }
2650
+ return;
2651
+ }
2387
2652
  if (event.key !== 'Enter' || event.shiftKey || event.altKey || chord) return;
2388
2653
  event.preventDefault();
2389
2654
  splitSegment(id, event.currentTarget ?? event.target);
@@ -2400,8 +2665,11 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2400
2665
  const vw = window.innerWidth || 1280;
2401
2666
  const vh = window.innerHeight || 800;
2402
2667
  const height = ui.minimized ? HEAD_H : ui.height;
2403
- const x = Math.min(Math.max(8, start.x + moveEvent.clientX - start.px), Math.max(8, vw - ui.width - 8));
2404
- const y = Math.min(Math.max(8, start.y + moveEvent.clientY - start.py), Math.max(8, vh - height - 8));
2668
+ const minY = hostChromeTop() + EDGE_GAP;
2669
+ const x = Math.min(Math.max(EDGE_GAP, start.x + moveEvent.clientX - start.px),
2670
+ Math.max(EDGE_GAP, vw - ui.width - EDGE_GAP));
2671
+ const y = Math.min(Math.max(minY, start.y + moveEvent.clientY - start.py),
2672
+ Math.max(minY, vh - height - EDGE_GAP));
2405
2673
  setUi((previous) => ({ ...previous, x, y }));
2406
2674
  };
2407
2675
  const stop = () => {
@@ -2427,17 +2695,20 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2427
2695
  const move = (moveEvent) => {
2428
2696
  const vw = window.innerWidth || 1280;
2429
2697
  const vh = window.innerHeight || 800;
2430
- const width = Math.min(Math.max(MIN_W, start.width + moveEvent.clientX - start.px), Math.max(MIN_W, vw - 16));
2431
- const height = Math.min(Math.max(MIN_H, start.height + moveEvent.clientY - start.py), Math.max(MIN_H, vh - 16));
2698
+ const minY = hostChromeTop() + EDGE_GAP;
2699
+ const width = Math.min(Math.max(MIN_W, start.width + moveEvent.clientX - start.px),
2700
+ Math.max(MIN_W, vw - EDGE_GAP * 2));
2701
+ const height = Math.min(Math.max(MIN_H, start.height + moveEvent.clientY - start.py),
2702
+ Math.max(MIN_H, vh - minY - EDGE_GAP));
2432
2703
  setUi((previous) => {
2433
- const maxX = Math.max(8, vw - width - 8);
2434
- const maxY = Math.max(8, vh - height - 8);
2704
+ const maxX = Math.max(EDGE_GAP, vw - width - EDGE_GAP);
2705
+ const maxY = Math.max(minY, vh - height - EDGE_GAP);
2435
2706
  return {
2436
2707
  ...previous,
2437
2708
  width,
2438
2709
  height,
2439
- x: typeof previous.x === 'number' ? Math.min(previous.x, maxX) : previous.x,
2440
- y: typeof previous.y === 'number' ? Math.min(previous.y, maxY) : previous.y,
2710
+ x: typeof previous.x === 'number' ? Math.min(Math.max(EDGE_GAP, previous.x), maxX) : previous.x,
2711
+ y: typeof previous.y === 'number' ? Math.min(Math.max(minY, previous.y), maxY) : previous.y,
2441
2712
  };
2442
2713
  });
2443
2714
  };
@@ -2465,8 +2736,9 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2465
2736
  const totalChars = segments.reduce((sum, segment) => sum + segment.text.length, 0);
2466
2737
  const displayWidth = ui.width;
2467
2738
  const displayHeight = ui.minimized ? HEAD_H : ui.height;
2468
- const fallbackX = Math.max(8, ((typeof window !== 'undefined' && window.innerWidth) || 1280) - displayWidth - 24);
2469
- const fallbackY = Math.max(8, ((typeof window !== 'undefined' && window.innerHeight) || 800) - displayHeight - 24);
2739
+ const fallbackX = Math.max(EDGE_GAP, ((typeof window !== 'undefined' && window.innerWidth) || 1280) - displayWidth - 24);
2740
+ const fallbackY = Math.max(hostChromeTop() + EDGE_GAP,
2741
+ ((typeof window !== 'undefined' && window.innerHeight) || 800) - displayHeight - 24);
2470
2742
  const left = typeof ui.x === 'number' ? ui.x : fallbackX;
2471
2743
  const top = typeof ui.y === 'number' ? ui.y : fallbackY;
2472
2744
 
@@ -2622,17 +2894,24 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2622
2894
  {
2623
2895
  type: 'button',
2624
2896
  className: 'dsh-np-btn',
2625
- 'data-active': optimizeAll !== null ? 'true' : 'false',
2626
- title: optimizeAll !== null
2627
- ? t('optimizeAllProgress', { done: optimizeAll.done, total: optimizeAll.total })
2628
- : t('optimizeAll'),
2629
- 'aria-label': t('optimizeAll'),
2630
- disabled: activeSegments.length === 0 || optimizeAll !== null,
2631
- onClick: optimizeAllSegments,
2897
+ 'data-active': optimizeBatch !== null || batchUndo !== null ? 'true' : 'false',
2898
+ title: optimizeBatch !== null
2899
+ ? t('optimizeAllProgress', { done: optimizeBatch.done, total: optimizeBatch.total })
2900
+ : batchUndo !== null
2901
+ ? t('undoOptimize')
2902
+ : selectedCount > 0
2903
+ ? t('optimizeSelected', { n: selectedCount })
2904
+ : t('optimizeSelectHint'),
2905
+ 'aria-label': batchUndo !== null
2906
+ ? t('undoOptimize')
2907
+ : selectedCount > 0 ? t('optimizeSelected', { n: selectedCount }) : t('optimizeSelectHint'),
2908
+ disabled: optimizeBatch !== null
2909
+ || (batchUndo === null && selectedCount === 0),
2910
+ onClick: batchUndo !== null ? undoBatchOptimization : optimizeSelectedSegments,
2632
2911
  },
2633
- optimizeAll !== null
2912
+ optimizeBatch !== null
2634
2913
  ? h('span', { className: 'dsh-np-mini-spinner' })
2635
- : h(SparkleIcon, { size: 14 }),
2914
+ : batchUndo !== null ? h(UndoIcon, { size: 14 }) : h(SparkleIcon, { size: 14 }),
2636
2915
  ),
2637
2916
  h(
2638
2917
  'button',
@@ -2720,6 +2999,15 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2720
2999
  const activeCards = activeSegments.map((segment, index) => {
2721
3000
  const images = Array.isArray(segment.images) ? segment.images : [];
2722
3001
  const empty = segment.text.trim() === '' && images.length === 0;
3002
+ const titleValue = segment.title ?? '';
3003
+ const showTitleInput = titleValue !== '' || titleEditing === segment.id;
3004
+ const undoRecord = segmentUndo.get(segment.id);
3005
+ const canUndoOptimize = undoRecord !== undefined && undoRecord.after === segment.text;
3006
+ const optimizingNow = optimizing.has(segment.id);
3007
+ const aiBusy = optimizingNow || optimizeBatch !== null;
3008
+ const aiTitle = aiBusy
3009
+ ? t('optimizing')
3010
+ : canUndoOptimize ? t('undoOptimize') : t('optimizeSegment');
2723
3011
  return h(
2724
3012
  'article',
2725
3013
  {
@@ -2786,28 +3074,43 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2786
3074
  },
2787
3075
  String(index + 1).padStart(2, '0'),
2788
3076
  ),
2789
- h('input', {
2790
- type: 'text',
2791
- className: 'dsh-np-title-input',
2792
- value: segment.title ?? '',
2793
- placeholder: t('titlePlaceholder'),
2794
- maxLength: MAX_TITLE_CHARS,
2795
- 'aria-label': t('segmentTitle'),
2796
- spellCheck: false,
2797
- onChange: (event) => updateSegmentTitle(segment.id, event.target?.value ?? ''),
2798
- onKeyDown: (event) => {
2799
- if (event.key !== 'Enter') return;
2800
- event.preventDefault();
2801
- try {
2802
- event.currentTarget?.blur?.();
2803
- } catch {
2804
- /* ignore */
2805
- }
2806
- },
2807
- onBlur: () => {
2808
- if (dirtyRef.current) saveNowRef.current?.();
2809
- },
2810
- }),
3077
+ showTitleInput
3078
+ ? h('input', {
3079
+ type: 'text',
3080
+ className: 'dsh-np-title-input',
3081
+ value: titleValue,
3082
+ placeholder: t('titlePlaceholder'),
3083
+ maxLength: MAX_TITLE_CHARS,
3084
+ autoFocus: titleValue === '',
3085
+ 'aria-label': t('segmentTitle'),
3086
+ spellCheck: false,
3087
+ onFocus: () => setTitleEditing(segment.id),
3088
+ onChange: (event) => updateSegmentTitle(segment.id, event.target?.value ?? ''),
3089
+ onKeyDown: (event) => {
3090
+ if (event.key !== 'Enter') return;
3091
+ event.preventDefault();
3092
+ try {
3093
+ event.currentTarget?.blur?.();
3094
+ } catch {
3095
+ /* ignore */
3096
+ }
3097
+ },
3098
+ onBlur: () => {
3099
+ if (dirtyRef.current) saveNowRef.current?.();
3100
+ if ((segment.title ?? '') === '') setTitleEditing('');
3101
+ },
3102
+ })
3103
+ : h(
3104
+ 'button',
3105
+ {
3106
+ type: 'button',
3107
+ className: 'dsh-np-title-add',
3108
+ title: t('segmentTitle'),
3109
+ 'aria-label': t('segmentTitle'),
3110
+ onClick: () => setTitleEditing(segment.id),
3111
+ },
3112
+ t('titlePlaceholder'),
3113
+ ),
2811
3114
  h(
2812
3115
  'button',
2813
3116
  {
@@ -2861,14 +3164,16 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2861
3164
  type: 'button',
2862
3165
  className: 'dsh-np-segbtn',
2863
3166
  'data-ai': 'true',
2864
- title: optimizing.has(segment.id) ? t('optimizing') : t('optimizeSegment'),
2865
- 'aria-label': t('optimizeSegment'),
2866
- disabled: empty || optimizing.has(segment.id) || optimizeAll !== null,
2867
- onClick: () => optimizeSegment(segment.id),
3167
+ title: aiTitle,
3168
+ 'aria-label': aiTitle,
3169
+ disabled: aiBusy || (canUndoOptimize ? false : empty),
3170
+ onClick: canUndoOptimize
3171
+ ? () => undoOptimizeSegment(segment.id)
3172
+ : () => optimizeSegment(segment.id),
2868
3173
  },
2869
- optimizing.has(segment.id)
3174
+ optimizingNow
2870
3175
  ? h('span', { className: 'dsh-np-mini-spinner' })
2871
- : h(SparkleIcon, { size: 14 }),
3176
+ : canUndoOptimize ? h(UndoIcon, { size: 14 }) : h(SparkleIcon, { size: 14 }),
2872
3177
  ),
2873
3178
  h(
2874
3179
  'button',
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.4.0';
41
+ const VERSION = '0.5.0';
42
42
 
43
43
  /** Request body cap for note writes. */
44
44
  const BODY_LIMIT = 1024 * 1024;
@@ -55,6 +55,12 @@ const OPTIMIZE_TIMEOUT_MS = 120_000;
55
55
  /** Default output cap for one optimized segment. */
56
56
  const OPTIMIZE_MAX_TOKENS = 4096;
57
57
 
58
+ /** Entries kept in the in-process optimizer cache. */
59
+ const OPTIMIZE_CACHE_LIMIT = 64;
60
+
61
+ /** Maximum key + value characters kept in the optimizer cache. */
62
+ const OPTIMIZE_CACHE_CHARS = 2_000_000;
63
+
58
64
  /** Shared instruction for the optional writing assistant. */
59
65
  const OPTIMIZE_SYSTEM = [
60
66
  'You are a writing assistant for a developer notepad.',
@@ -238,6 +244,41 @@ function cleanOptimizedText(value) {
238
244
  return text;
239
245
  }
240
246
 
247
+ /**
248
+ * Results of earlier optimizer calls. Re-optimizing content that has not
249
+ * changed returns the previous answer instead of billing another model call.
250
+ * The cache is per host process and bounded by entry count and characters.
251
+ * @type {Map<string, string>}
252
+ */
253
+ const optimizeCache = new Map();
254
+ let optimizeCacheChars = 0;
255
+
256
+ /**
257
+ * @param {string} key
258
+ * @param {string} text
259
+ */
260
+ function cacheOptimizedText(key, text) {
261
+ const previous = optimizeCache.get(key);
262
+ if (previous !== undefined) {
263
+ optimizeCacheChars -= key.length + previous.length;
264
+ optimizeCache.delete(key);
265
+ }
266
+ optimizeCache.set(key, text);
267
+ optimizeCacheChars += key.length + text.length;
268
+ while (optimizeCache.size > OPTIMIZE_CACHE_LIMIT || optimizeCacheChars > OPTIMIZE_CACHE_CHARS) {
269
+ const oldest = optimizeCache.keys().next();
270
+ if (oldest.done === true) break;
271
+ const value = optimizeCache.get(oldest.value) ?? '';
272
+ optimizeCacheChars -= oldest.value.length + value.length;
273
+ optimizeCache.delete(oldest.value);
274
+ }
275
+ }
276
+
277
+ /** Workspace + title + text: everything the optimizer prompt depends on. */
278
+ function optimizeCacheKey(root, title, text) {
279
+ return `${root}\u0000${title}\u0000${text}`;
280
+ }
281
+
241
282
  /**
242
283
  * Ask the currently selected default model to improve one note segment.
243
284
  * The LLM service is optional: a deployment without one reports a stable
@@ -425,8 +466,16 @@ async function dispatchRequest(ctx, req, res) {
425
466
  `Segment is too large to optimize (${text.length} characters; limit ${MAX_OPTIMIZE_CHARS}).`,
426
467
  );
427
468
  }
428
- const optimized = await optimizeText(ctx, text, typeof body.title === 'string' ? body.title : '');
429
- sendJson(res, { ok: true, root, text: optimized });
469
+ const title = typeof body.title === 'string' ? body.title : '';
470
+ const cacheKey = optimizeCacheKey(root, title, text);
471
+ const cached = optimizeCache.get(cacheKey);
472
+ if (typeof cached === 'string' && cached !== '') {
473
+ sendJson(res, { ok: true, root, text: cached, cached: true });
474
+ return;
475
+ }
476
+ const optimized = await optimizeText(ctx, text, title);
477
+ cacheOptimizedText(cacheKey, optimized);
478
+ sendJson(res, { ok: true, root, text: optimized, cached: false });
430
479
  return;
431
480
  }
432
481
  if (action !== 'note') {
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, each card has an editable title; click an index to select, drag to reorder, merge a selection, and improve one or all cards with AI. Paste or pick images as path-based chips; removing a chip cleans up plugin-owned files. Pending counts appear on the hidden launcher and the sidebar workspace row. Insert, replace, or send one or many; sending archives automatically. Archived segments can be shown, hidden, restored, and deleted. Resizable and dark-mode aware."
4
+ "description": "A per-workspace floating notepad in the session view. New segment cards lead the list; an empty title is just a placeholder 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. Paste or pick images as path-based chips; removing a chip cleans up plugin-owned files. Pending counts appear on the hidden launcher and the sidebar workspace row. Insert, replace, or send one or many; sending archives automatically. Archived segments can be shown, hidden, restored, and deleted. Auto refresh keeps the list scroll position, Ctrl+Shift+0 resets the panel geometry, the panel never covers the host title bar, and it is resizable and dark-mode aware."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "记事本",
4
- "description": "在会话界面显示一个按工作区共享的分段便签浮窗:新分段置顶,标题可编辑,点击序号选中,可拖拽排序、合并多段并用 AI 优化单段或全部;支持粘贴或选择图片并以路径附件形式引入,删除图片芯片会清理插件目录里的文件;隐藏按钮和侧边栏工作区显示未发送数量角标;支持单段/多段插入、替换与发送,发送后自动归档,归档可显示、隐藏、恢复与删除,浮窗可调整尺寸并适配深色模式。"
4
+ "description": "在会话界面显示一个按工作区共享的分段便签浮窗:新分段置顶,空标题点击后才显示输入框,点击序号选中,可拖拽排序、合并多段、用上下方向键切换分段;AI 优化只作用于选中的分段,优化后按钮变为撤销,相同内容直接复用上次结果;支持粘贴或选择图片并以路径附件形式引入,删除图片芯片会清理插件目录里的文件;隐藏按钮和侧边栏工作区显示未发送数量角标;支持单段/多段插入、替换与发送,发送后自动归档,归档可显示、隐藏、恢复与删除;自动刷新保持滚动位置,浮窗可调整尺寸,Ctrl+Shift+0 复位位置和尺寸,且不会越过窗口标题栏。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@restregular/dsh-notepad",
3
- "version": "0.4.0",
4
- "description": "Notepad — a per-workspace floating notepad in the DeepSeek Harness session view: newest segment cards first with editable titles, drag to reorder, merge selected segments, AI improve one or all, click an index to select, image chips with automatic file cleanup, pending-count badges on the hidden button and the sidebar workspace, batch insert / replace / send, archive management, resizable panel and dark-mode styling. · 记事本 — 在 DeepSeek Harness 会话界面显示一个按工作区共享的分段便签浮窗:新分段置顶且标题可编辑,可拖拽排序、合并选中分段、AI 优化单段或全部、点击序号选中,图片芯片删除时自动清理磁盘文件,隐藏按钮与侧边栏工作区显示未发送数量角标,支持单段/多段插入、替换与发送,发送自动归档,归档可显示隐藏和恢复删除,可调整尺寸并适配深色模式。",
3
+ "version": "0.5.0",
4
+ "description": "Notepad — a per-workspace floating notepad in the DeepSeek Harness session view: newest segment cards first with titles that appear only after a click, drag to reorder, merge selected segments, arrow keys to move between cards, AI improve limited to the selected segments with a one-click undo and cached results, image chips with automatic file cleanup, pending-count badges on the hidden button and the sidebar workspace, batch insert / replace / send, archive management, scroll position kept across automatic refreshes, resizable panel, Ctrl+Shift+0 geometry reset, a title-bar-safe position, and dark-mode styling. · 记事本 — 在 DeepSeek Harness 会话界面显示一个按工作区共享的分段便签浮窗:新分段置顶且空标题只在点击后显示输入框,可拖拽排序、合并选中分段、上下方向键切换分段、只对选中分段做 AI 优化,优化后按钮变为撤销并复用相同内容的缓存结果;图片芯片删除时自动清理磁盘文件;隐藏按钮与侧边栏工作区显示未发送数量角标;支持单段/多段插入、替换与发送,发送自动归档,归档可显示隐藏和恢复删除;自动刷新保持列表滚动位置;可调整尺寸,Ctrl+Shift+0 复位位置和尺寸,浮窗不会越过窗口标题栏,并适配深色模式。",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
7
7
  "main": "index.js",