@restregular/dsh-notepad 0.4.0 → 0.6.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/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',
@@ -86,6 +88,10 @@ window.__ModuleLoader__.load({
86
88
  insert: '插入到输入框',
87
89
  insertSegment: '插入此段',
88
90
  insertSelected: '插入选中的 {n} 段',
91
+ insertNewSession: '在新会话中插入',
92
+ insertNewSelected: '在新会话中插入选中的 {n} 段',
93
+ insertNewDone: '已在新会话中填入内容',
94
+ insertNewUnavailable: '无法新建会话,内容已复制',
89
95
  inserted: '已插入输入框',
90
96
  insertEmpty: '没有可插入的内容',
91
97
  insertUnavailable: '输入框暂不可用,内容已复制',
@@ -97,6 +103,10 @@ window.__ModuleLoader__.load({
97
103
  send: '发送',
98
104
  sendSegment: '发送此段',
99
105
  sendSelected: '发送选中的 {n} 段',
106
+ sendNewSession: '在新会话中发送',
107
+ sendNewSelected: '在新会话中发送选中的 {n} 段',
108
+ sendNewDone: '已在新会话中发送',
109
+ sendNewUnavailable: '无法新建会话,内容已复制',
100
110
  sending: '发送中…',
101
111
  sent: '已发送',
102
112
  sendFailed: '发送失败',
@@ -114,11 +124,14 @@ window.__ModuleLoader__.load({
114
124
  mergeHint: '至少选择 2 段才能合并',
115
125
  mergedToast: '已合并 {n} 段',
116
126
  optimizeSegment: 'AI 优化此段',
117
- optimizeAll: 'AI 优化全部分段',
127
+ optimizeSelected: 'AI 优化选中的 {n} 段',
128
+ optimizeSelectHint: '勾选分段后可批量优化',
118
129
  optimizeAllProgress: 'AI 优化中 {done}/{total}',
119
130
  optimizing: '优化中…',
131
+ undoOptimize: '撤销优化',
120
132
  optimizedToast: '已优化此段',
121
133
  optimizedCount: '已优化 {n} 段',
134
+ optimizeUndone: '已撤销优化',
122
135
  optimizeEmpty: '没有可优化的文字',
123
136
  optimizeTooLong: '内容太长,无法优化',
124
137
  optimizeUnavailable: 'AI 服务不可用',
@@ -142,6 +155,12 @@ window.__ModuleLoader__.load({
142
155
  restoredToast: '已恢复',
143
156
  restoredCount: '已恢复 {n} 段',
144
157
  deleteSegment: '删除此段',
158
+ deleteActiveSegment: '删除此段(不可恢复)',
159
+ deleteConfirmTitle: '删除这个分段?',
160
+ deleteConfirmBody: '删除后无法恢复。分段里的文字和图片引用会一起移除。',
161
+ deleteConfirmHint: '此操作不可撤销',
162
+ deleteConfirmCancel: '取消',
163
+ deleteConfirmOk: '删除',
145
164
  deletedToast: '已删除',
146
165
  clearArchive: '清空归档',
147
166
  clearedArchive: '已清空 {n} 段归档',
@@ -155,12 +174,24 @@ window.__ModuleLoader__.load({
155
174
  show: '显示记事本(Ctrl+Shift+N)',
156
175
  chars: '{n} 字符',
157
176
  segments: '{n} 段',
158
- hint: 'Enter 新建分段 · Shift+Enter 换行 · Ctrl+Enter 插入 · 支持粘贴图片',
177
+ hintNext: '点击查看下一条快捷键',
178
+ hintEnter: 'Enter 新建分段',
179
+ hintNewline: 'Shift+Enter 分段内换行',
180
+ hintInsert: 'Ctrl+Enter 插入当前分段',
181
+ hintToggle: 'Ctrl+Shift+N 显示 / 隐藏浮窗',
182
+ hintReset: 'Ctrl+Shift+0 复位浮窗',
183
+ hintArrow: '↑ / ↓ 切换分段',
184
+ hintPaste: '粘贴图片自动保存到工作区',
185
+ resetShortcut: '复位记事本位置和尺寸',
159
186
  workspace: '工作区',
160
187
  resize: '拖动调整大小',
188
+ uiReset: '已复位浮窗位置和尺寸',
161
189
  addImage: '添加图片',
162
190
  removeImage: '移除图片',
163
191
  openImage: '打开图片',
192
+ imagePreview: '图片预览',
193
+ closePreview: '关闭预览',
194
+ openOriginal: '打开原图',
164
195
  imageAdded: '已添加 {n} 张图片',
165
196
  imageFailed: '图片添加失败',
166
197
  imageUnsupported: '只支持 PNG / JPEG / WebP / GIF 图片',
@@ -190,6 +221,10 @@ window.__ModuleLoader__.load({
190
221
  insert: 'Insert into composer',
191
222
  insertSegment: 'Insert this segment',
192
223
  insertSelected: 'Insert {n} selected',
224
+ insertNewSession: 'Insert into a new session',
225
+ insertNewSelected: 'Insert {n} selected into a new session',
226
+ insertNewDone: 'Filled into a new session',
227
+ insertNewUnavailable: 'Could not create a session; copied instead',
193
228
  inserted: 'Inserted into composer',
194
229
  insertEmpty: 'Nothing to insert',
195
230
  insertUnavailable: 'Composer unavailable; copied instead',
@@ -201,6 +236,10 @@ window.__ModuleLoader__.load({
201
236
  send: 'Send',
202
237
  sendSegment: 'Send this segment',
203
238
  sendSelected: 'Send {n} selected',
239
+ sendNewSession: 'Send into a new session',
240
+ sendNewSelected: 'Send {n} selected into a new session',
241
+ sendNewDone: 'Sent into a new session',
242
+ sendNewUnavailable: 'Could not create a session; copied instead',
204
243
  sending: 'Sending…',
205
244
  sent: 'Sent',
206
245
  sendFailed: 'Send failed',
@@ -218,11 +257,14 @@ window.__ModuleLoader__.load({
218
257
  mergeHint: 'Select at least 2 segments to merge',
219
258
  mergedToast: 'Merged {n} segments',
220
259
  optimizeSegment: 'Improve this segment with AI',
221
- optimizeAll: 'Improve all segments with AI',
260
+ optimizeSelected: 'Improve {n} selected with AI',
261
+ optimizeSelectHint: 'Select segments to improve',
222
262
  optimizeAllProgress: 'Improving {done}/{total}',
223
263
  optimizing: 'Improving…',
264
+ undoOptimize: 'Undo improvement',
224
265
  optimizedToast: 'Segment improved',
225
266
  optimizedCount: 'Improved {n} segments',
267
+ optimizeUndone: 'Improvement undone',
226
268
  optimizeEmpty: 'Nothing to improve',
227
269
  optimizeTooLong: 'Too long to improve',
228
270
  optimizeUnavailable: 'AI service unavailable',
@@ -246,6 +288,12 @@ window.__ModuleLoader__.load({
246
288
  restoredToast: 'Restored',
247
289
  restoredCount: 'Restored {n}',
248
290
  deleteSegment: 'Delete this segment',
291
+ deleteActiveSegment: 'Delete this segment (cannot be undone)',
292
+ deleteConfirmTitle: 'Delete this segment?',
293
+ deleteConfirmBody: 'This cannot be undone. The text and image references in the segment will be removed too.',
294
+ deleteConfirmHint: 'This action cannot be undone',
295
+ deleteConfirmCancel: 'Cancel',
296
+ deleteConfirmOk: 'Delete',
249
297
  deletedToast: 'Deleted',
250
298
  clearArchive: 'Clear archive',
251
299
  clearedArchive: 'Cleared {n} archived',
@@ -259,12 +307,24 @@ window.__ModuleLoader__.load({
259
307
  show: 'Show notepad (Ctrl+Shift+N)',
260
308
  chars: '{n} chars',
261
309
  segments: '{n} segments',
262
- hint: 'Enter new segment · Shift+Enter newline · Ctrl+Enter insert · Paste images',
310
+ hintNext: 'Click for the next shortcut',
311
+ hintEnter: 'Enter creates a segment',
312
+ hintNewline: 'Shift+Enter adds a line break',
313
+ hintInsert: 'Ctrl+Enter inserts the current segment',
314
+ hintToggle: 'Ctrl+Shift+N shows / hides the notepad',
315
+ hintReset: 'Ctrl+Shift+0 resets the floating window',
316
+ hintArrow: '↑ / ↓ switch segments',
317
+ hintPaste: 'Pasted images are saved in the workspace',
318
+ resetShortcut: 'Reset the notepad position and size',
263
319
  workspace: 'Workspace',
264
320
  resize: 'Drag to resize',
321
+ uiReset: 'Window position and size reset',
265
322
  addImage: 'Add image',
266
323
  removeImage: 'Remove image',
267
324
  openImage: 'Open image',
325
+ imagePreview: 'Image preview',
326
+ closePreview: 'Close preview',
327
+ openOriginal: 'Open original',
268
328
  imageAdded: 'Added {n} image(s)',
269
329
  imageFailed: 'Could not add image',
270
330
  imageUnsupported: 'Only PNG / JPEG / WebP / GIF images are supported',
@@ -277,6 +337,17 @@ window.__ModuleLoader__.load({
277
337
  },
278
338
  };
279
339
 
340
+ /** Rotating shortcut tips shown one at a time in the footer. */
341
+ const HINT_KEYS = [
342
+ 'hintEnter',
343
+ 'hintNewline',
344
+ 'hintInsert',
345
+ 'hintToggle',
346
+ 'hintReset',
347
+ 'hintArrow',
348
+ 'hintPaste',
349
+ ];
350
+
280
351
  function preferredLanguage() {
281
352
  try {
282
353
  const tag = String((typeof navigator !== 'undefined' && navigator.language) || 'zh').toLowerCase();
@@ -548,6 +619,78 @@ window.__ModuleLoader__.load({
548
619
  );
549
620
  }
550
621
 
622
+ /** Curved arrow: undo the last AI improvement. */
623
+ function UndoIcon({ size = 16 }) {
624
+ return h(
625
+ Svg,
626
+ { size, viewBox: '0 0 16 16' },
627
+ h('path', {
628
+ d: 'M5.4 4.2 2.8 6.8l2.6 2.6',
629
+ stroke: 'currentColor', strokeWidth: 1.35,
630
+ strokeLinecap: 'round', strokeLinejoin: 'round',
631
+ }),
632
+ h('path', {
633
+ d: 'M3 6.8h5.3c2.7 0 4.9 2.2 4.9 4.9',
634
+ stroke: 'currentColor', strokeWidth: 1.35,
635
+ strokeLinecap: 'round', strokeLinejoin: 'round',
636
+ }),
637
+ );
638
+ }
639
+
640
+ /** Two panels, the second with a plus: start a new session. */
641
+ function NewSessionIcon({ size = 16 }) {
642
+ return h(
643
+ Svg,
644
+ { size, viewBox: '0 0 16 16' },
645
+ h('rect', {
646
+ x: 1.9, y: 3.2, width: 5.1, height: 9.4, rx: 1.4,
647
+ stroke: 'currentColor', strokeWidth: 1.25,
648
+ }),
649
+ h('rect', {
650
+ x: 8.7, y: 3.2, width: 5.4, height: 9.4, rx: 1.4,
651
+ stroke: 'currentColor', strokeWidth: 1.25,
652
+ }),
653
+ h('path', {
654
+ d: 'M11.4 6.1v3.8M9.5 8h3.8',
655
+ stroke: 'currentColor', strokeWidth: 1.25, strokeLinecap: 'round',
656
+ }),
657
+ );
658
+ }
659
+
660
+ /** Circular arrow: reset the floating window geometry. */
661
+ function ResetIcon({ size = 16 }) {
662
+ return h(
663
+ Svg,
664
+ { size, viewBox: '0 0 16 16' },
665
+ h('path', {
666
+ d: 'M13 8a5 5 0 1 1-1.6-3.7',
667
+ stroke: 'currentColor', strokeWidth: 1.35, strokeLinecap: 'round',
668
+ }),
669
+ h('path', {
670
+ d: 'M13.3 2.7v3.4H9.9',
671
+ stroke: 'currentColor', strokeWidth: 1.35,
672
+ strokeLinecap: 'round', strokeLinejoin: 'round',
673
+ }),
674
+ );
675
+ }
676
+
677
+ /** Arrow leaving a frame: open the original image. */
678
+ function OpenIcon({ size = 16 }) {
679
+ return h(
680
+ Svg,
681
+ { size, viewBox: '0 0 16 16' },
682
+ h('path', {
683
+ d: 'M9.4 3.2h3.4v3.4M12.8 3.2 7.6 8.4',
684
+ stroke: 'currentColor', strokeWidth: 1.3,
685
+ strokeLinecap: 'round', strokeLinejoin: 'round',
686
+ }),
687
+ h('path', {
688
+ d: 'M12.4 9.4v2.3c0 .7-.6 1.3-1.3 1.3H4.6c-.7 0-1.3-.6-1.3-1.3V5.2c0-.7.6-1.3 1.3-1.3h2.3',
689
+ stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round',
690
+ }),
691
+ );
692
+ }
693
+
551
694
  /** Two arrows converging: merge selected segments into one. */
552
695
  function MergeIcon({ size = 16 }) {
553
696
  return h(
@@ -608,7 +751,7 @@ window.__ModuleLoader__.load({
608
751
  .dsh-np-btn[data-accent=true]{background:var(--np-accent);color:var(--np-accent-fg)}
609
752
  .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
753
  .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}
754
+ .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-preview-btn:focus-visible,.dsh-np-confirm-btn:focus-visible,button.dsh-np-hint: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
755
  .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
756
  .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
757
  .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 +776,7 @@ window.__ModuleLoader__.load({
633
776
  .dsh-np-title-input::placeholder{color:var(--dsw-alias-label-caption,#9aa3b2);font-weight:400}
634
777
  .dsh-np-title-input:hover{background:var(--dsw-alias-interactive-bg-hover,rgb(38 49 72/.06))}
635
778
  .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)}
779
+ .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
780
  .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
781
  .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
782
  .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}
@@ -640,6 +784,11 @@ window.__ModuleLoader__.load({
640
784
  .dsh-np-segbtn[data-ai=true]{color:var(--np-accent);opacity:1}
641
785
  .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))}
642
786
  .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}
791
+ .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}
643
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}
644
793
  .dsh-np-seg-text::placeholder{color:var(--dsw-alias-label-caption,#9aa3b2)}
645
794
  .dsh-np-seg-text:disabled{opacity:.6}
@@ -677,6 +826,24 @@ window.__ModuleLoader__.load({
677
826
  .dsh-np-retry{flex:none;padding:0;border:0;background:none;color:var(--dsw-alias-link,#4176e6);font:inherit;font-size:10.5px;cursor:pointer;text-decoration:underline}
678
827
  .dsh-np-count{flex:none;font-variant-numeric:tabular-nums}
679
828
  .dsh-np-hint{margin-left:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:.85;font-size:10px}
829
+ button.dsh-np-hint{padding:0;border:0;background:none;color:inherit;font:inherit;font-size:10px;cursor:pointer;text-align:right}
830
+ button.dsh-np-hint:hover{opacity:1;color:var(--dsw-alias-label-secondary,#4b5563);text-decoration:underline}
831
+ .dsh-np-preview{position:absolute;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;padding:44px 20px 28px;background:rgb(8 12 20/.74);backdrop-filter:blur(3px);animation:dsh-np-fade .14s ease-out}
832
+ .dsh-np-preview-img{display:block;max-width:100%;max-height:100%;object-fit:contain;border-radius:8px;background:#fff;box-shadow:0 16px 40px rgb(0 0 0/.35)}
833
+ .dsh-np-preview-bar{position:absolute;top:8px;right:8px;display:flex;align-items:center;gap:4px}
834
+ .dsh-np-preview-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:1px solid rgb(255 255 255/.18);border-radius:7px;background:rgb(15 23 42/.55);color:#f8fafc;cursor:pointer;backdrop-filter:blur(4px)}
835
+ .dsh-np-preview-btn:hover{background:rgb(15 23 42/.82)}
836
+ .dsh-np-preview-name{position:absolute;left:12px;bottom:8px;max-width:calc(100% - 24px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#e5e7eb;font-size:11px}
837
+ .dsh-np-confirm{position:absolute;inset:0;z-index:21;display:flex;align-items:center;justify-content:center;padding:16px;background:rgb(8 12 20/.55);backdrop-filter:blur(2px);animation:dsh-np-fade .14s ease-out}
838
+ .dsh-np-confirm-card{width:min(100%,300px);padding:14px;border:1px solid var(--dsw-alias-border-l2,rgb(15 23 42/.14));border-radius:12px;background:var(--np-surface);box-shadow:var(--dsw-elevation-prominent,0 20px 50px rgb(15 23 42/.22))}
839
+ .dsh-np-confirm-title{font-size:13px;font-weight:650;color:var(--dsw-alias-label-primary,#1f2430)}
840
+ .dsh-np-confirm-body{margin-top:6px;color:var(--dsw-alias-label-secondary,#4b5563);font-size:12px;line-height:1.55}
841
+ .dsh-np-confirm-hint{margin-top:8px;color:var(--dsw-alias-state-error-primary,#e25555);font-size:11px}
842
+ .dsh-np-confirm-actions{display:flex;justify-content:flex-end;gap:6px;margin-top:12px}
843
+ .dsh-np-confirm-btn{height:28px;padding:0 10px;border:1px solid var(--dsw-alias-border-l2,rgb(15 23 42/.14));border-radius:7px;background:var(--np-surface-2);color:var(--dsw-alias-label-secondary,#4b5563);font:inherit;font-size:12px;cursor:pointer}
844
+ .dsh-np-confirm-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgb(38 49 72/.06));color:var(--dsw-alias-label-primary,#1f2430)}
845
+ .dsh-np-confirm-btn[data-danger=true]{border-color:transparent;background:var(--dsw-alias-state-error-primary,#e25555);color:#fff}
846
+ .dsh-np-confirm-btn[data-danger=true]:hover{background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#e25555) 86%,#000);color:#fff}
680
847
  .dsh-np-toast{position:absolute;left:50%;bottom:38px;transform:translateX(-50%);z-index:2;display:flex;align-items:center;gap:8px;max-width:calc(100% - 32px);padding:5px 10px;border-radius:8px;background:var(--np-surface-2);border:1px solid var(--dsw-alias-border-l2,rgb(15 23 42/.12));box-shadow:var(--dsw-elevation-lv2,0 8px 24px rgb(15 23 42/.18));color:var(--dsw-alias-label-primary,#1f2430);font-size:11.5px;white-space:nowrap;overflow:hidden;animation:dsh-np-rise .16s ease-out}
681
848
  .dsh-np-toast-text{overflow:hidden;text-overflow:ellipsis}
682
849
  .dsh-np-toast-action{flex:none;padding:0;border:0;background:none;color:var(--dsw-alias-link,#4176e6);font:inherit;font-size:11.5px;font-weight:600;cursor:pointer;text-decoration:underline}
@@ -691,6 +858,7 @@ window.__ModuleLoader__.load({
691
858
  [data-row-key^="workspace:"][data-np-unsent]{position:relative}
692
859
  [data-row-key^="workspace:"][data-np-unsent]::after{content:attr(data-np-unsent);position:absolute;top:-4px;left:1px;z-index:2;min-width:15px;height:15px;padding:0 4px;box-sizing:border-box;border-radius:8px;background:var(--dsw-alias-state-business-primary,#4176e6);color:#fff;font-size:9.5px;font-weight:700;line-height:15px;text-align:center;font-variant-numeric:tabular-nums;pointer-events:none;box-shadow:0 0 0 1.5px var(--dsw-specific-sidebar-fill,var(--dsw-alias-bg-layer-1,#fff))}
693
860
  @keyframes dsh-np-pulse{0%,100%{opacity:1}50%{opacity:.35}}
861
+ @keyframes dsh-np-fade{from{opacity:0}to{opacity:1}}
694
862
  @keyframes dsh-np-rise{from{opacity:0;transform:translate(-50%,4px)}to{opacity:1;transform:translate(-50%,0)}}
695
863
  @keyframes dsh-np-item-in{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}}
696
864
  @keyframes dsh-np-spin{to{transform:rotate(360deg)}}
@@ -698,7 +866,7 @@ body[data-ds-dark-theme] .dsh-np-card{--np-accent-fg:#0f1115;box-shadow:0 18px 4
698
866
  body[data-ds-dark-theme] .dsh-np-seg[data-selected=true]{background:color-mix(in srgb,var(--np-accent) 14%,var(--dsw-alias-bg-layer-2,#2c2c2e))}
699
867
  body[data-ds-dark-theme] .dsh-np-btn[data-accent=true]:hover:not(:disabled){background:color-mix(in srgb,var(--np-accent) 84%,#fff)}
700
868
  body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
701
- @media (prefers-reduced-motion:reduce){.dsh-np-card,.dsh-np-body,.dsh-np-archive,.dsh-np-restore,.dsh-np-seg,.dsh-np-toast{transition:none!important;animation:none!important}.dsh-np-dot[data-state=saving]{animation:none}.dsh-np-img-spinner,.dsh-np-mini-spinner{animation:none}}`;
869
+ @media (prefers-reduced-motion:reduce){.dsh-np-card,.dsh-np-body,.dsh-np-archive,.dsh-np-restore,.dsh-np-seg,.dsh-np-toast,.dsh-np-actiongroup,.dsh-np-group-extra,.dsh-np-preview,.dsh-np-confirm{transition:none!important;animation:none!important}.dsh-np-dot[data-state=saving]{animation:none}.dsh-np-img-spinner,.dsh-np-mini-spinner{animation:none}}`;
702
870
 
703
871
  // ------------------------------------------------------------------
704
872
  // Helpers
@@ -1032,6 +1200,43 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1032
1200
  return parts.length === 0 ? String(root ?? '') : parts[parts.length - 1];
1033
1201
  }
1034
1202
 
1203
+ /**
1204
+ * Height of the chrome the host draws over the page (the Windows title bar
1205
+ * with maximize / minimize / close, or the macOS traffic lights). The
1206
+ * floating card must never move above that strip. The host exposes it as a
1207
+ * CSS variable on <html>; standalone browsers have none, so this is 0.
1208
+ * @returns {number}
1209
+ */
1210
+ function hostChromeTop() {
1211
+ try {
1212
+ const root = typeof document !== 'undefined' ? document.documentElement : null;
1213
+ if (root === null || root === undefined) return 0;
1214
+ const hasAttribute = (name) => typeof root.hasAttribute === 'function' && root.hasAttribute(name);
1215
+ const readVar = (name) => {
1216
+ try {
1217
+ if (typeof window === 'undefined' || typeof window.getComputedStyle !== 'function') return NaN;
1218
+ const style = window.getComputedStyle(root);
1219
+ return Number.parseFloat(style?.getPropertyValue(name) ?? '');
1220
+ } catch {
1221
+ return NaN;
1222
+ }
1223
+ };
1224
+ // `--dsh-frame-chrome-top` is 0 while the host is in fullscreen.
1225
+ const chrome = readVar('--dsh-frame-chrome-top');
1226
+ if (Number.isFinite(chrome)) return Math.max(0, chrome);
1227
+ const clearance = readVar('--dsh-frame-top-clearance');
1228
+ if (Number.isFinite(clearance) && clearance > 0) return clearance;
1229
+ if (hasAttribute('data-windows-titlebar')) {
1230
+ const titlebar = readVar('--dsh-windows-titlebar-height');
1231
+ return Number.isFinite(titlebar) && titlebar > 0 ? titlebar : 32;
1232
+ }
1233
+ const platform = typeof root.getAttribute === 'function' ? root.getAttribute('data-platform') : '';
1234
+ return platform === 'darwin' ? 48 : 0;
1235
+ } catch {
1236
+ return 0;
1237
+ }
1238
+ }
1239
+
1035
1240
  function readStoredUi() {
1036
1241
  const base = {
1037
1242
  x: null,
@@ -1064,17 +1269,18 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1064
1269
  function clampUi(ui) {
1065
1270
  const vw = (typeof window !== 'undefined' && window.innerWidth) || 1280;
1066
1271
  const vh = (typeof window !== 'undefined' && window.innerHeight) || 800;
1067
- const widthLimit = Math.max(240, vw - 16);
1068
- const heightLimit = Math.max(180, vh - 16);
1272
+ const minTop = hostChromeTop() + EDGE_GAP;
1273
+ const widthLimit = Math.max(240, vw - EDGE_GAP * 2);
1274
+ const heightLimit = Math.max(180, vh - minTop - EDGE_GAP);
1069
1275
  const width = Math.min(Math.max(MIN_W, Number(ui.width) || DEFAULT_W), widthLimit);
1070
1276
  const height = Math.min(Math.max(MIN_H, Number(ui.height) || DEFAULT_H), heightLimit);
1071
1277
  const displayHeight = ui.minimized ? HEAD_H : height;
1072
1278
  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));
1279
+ ? Math.max(EDGE_GAP, vw - width - 24)
1280
+ : Math.min(Math.max(EDGE_GAP, ui.x), Math.max(EDGE_GAP, vw - width - EDGE_GAP));
1075
1281
  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));
1282
+ ? Math.max(minTop, vh - displayHeight - 24)
1283
+ : Math.min(Math.max(minTop, ui.y), Math.max(minTop, vh - displayHeight - EDGE_GAP));
1078
1284
  return { ...ui, x, y, width, height };
1079
1285
  }
1080
1286
 
@@ -1185,6 +1391,38 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1185
1391
  }
1186
1392
  }
1187
1393
 
1394
+ /** Mounted notepads register here so a host shortcut can reset them. */
1395
+ const resetHandlers = new Set();
1396
+
1397
+ /** Whether the host shortcut service already owns Ctrl/Cmd+Shift+0. */
1398
+ let resetShortcutHandledByHost = false;
1399
+
1400
+ /** Ask every mounted notepad to reset its position and size. */
1401
+ function requestUiReset() {
1402
+ let handled = false;
1403
+ for (const handler of resetHandlers) {
1404
+ try {
1405
+ handler();
1406
+ handled = true;
1407
+ } catch {
1408
+ /* One failing overlay must not block the others. */
1409
+ }
1410
+ }
1411
+ return handled;
1412
+ }
1413
+
1414
+ /** Read the General Settings "send behavior while busy" preference. */
1415
+ function busyEnterPreference(ctx) {
1416
+ try {
1417
+ const forms = ctx.get('configForms') ?? ctx.configForms;
1418
+ const form = typeof forms?.get === 'function' ? forms.get('ui-conversation') : undefined;
1419
+ const value = form?.getSnapshot?.()?.value;
1420
+ return value?.busyEnter === 'steer' ? 'steer' : 'queue';
1421
+ } catch {
1422
+ return 'queue';
1423
+ }
1424
+ }
1425
+
1188
1426
  // ------------------------------------------------------------------
1189
1427
  // Overlay
1190
1428
  // ------------------------------------------------------------------
@@ -1201,7 +1439,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1201
1439
  const [error, setError] = useState('');
1202
1440
  const [savedAt, setSavedAt] = useState(null);
1203
1441
  const [toast, setToast] = useState(null);
1204
- const [ui, setUi] = useState(readStoredUi);
1442
+ const [ui, setUi] = useState(() => clampUi(readStoredUi()));
1205
1443
  const [sending, setSending] = useState(false);
1206
1444
  const [resizing, setResizing] = useState(false);
1207
1445
  const [uploading, setUploading] = useState({});
@@ -1209,7 +1447,13 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1209
1447
  const [reorderDrag, setReorderDrag] = useState('');
1210
1448
  const [reorderOver, setReorderOver] = useState(null);
1211
1449
  const [optimizing, setOptimizing] = useState(() => new Set());
1212
- const [optimizeAll, setOptimizeAll] = useState(null);
1450
+ const [optimizeBatch, setOptimizeBatch] = useState(null);
1451
+ const [segmentUndo, setSegmentUndo] = useState(() => new Map());
1452
+ const [batchUndo, setBatchUndo] = useState(null);
1453
+ const [titleEditing, setTitleEditing] = useState('');
1454
+ const [previewImage, setPreviewImage] = useState(null);
1455
+ const [deleteConfirm, setDeleteConfirm] = useState('');
1456
+ const [hintIndex, setHintIndex] = useState(0);
1213
1457
  const [wsCounts, setWsCounts] = useState({});
1214
1458
  const [currentWsId, setCurrentWsId] = useState('');
1215
1459
 
@@ -1235,9 +1479,13 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1235
1479
  const fileInputRefs = useRef(new Map());
1236
1480
  const listRef = useRef(null);
1237
1481
  const pendingFocusRef = useRef(null);
1482
+ const pendingScrollRef = useRef(null);
1483
+ const refreshSeqRef = useRef(0);
1238
1484
  const undoRef = useRef(null);
1239
1485
  const optimizingRef = useRef(new Set());
1240
- const optimizeAllRef = useRef(false);
1486
+ const optimizeBatchRef = useRef(false);
1487
+ const optimizeCacheRef = useRef(new Map());
1488
+ const batchUndoRef = useRef(null);
1241
1489
  const reorderDragRef = useRef('');
1242
1490
  const reorderOverRef = useRef(null);
1243
1491
  const imageDeleteTimersRef = useRef(new Map());
@@ -1259,6 +1507,17 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1259
1507
  if (changed) setSelection(pruned);
1260
1508
  };
1261
1509
 
1510
+ /** Keep the ref and the state in sync for the batch-optimize undo slot. */
1511
+ const setBatchUndoState = (value) => {
1512
+ batchUndoRef.current = value;
1513
+ setBatchUndo(value);
1514
+ };
1515
+
1516
+ /** Any local edit invalidates the "undo the last batch" offer. */
1517
+ const invalidateBatchUndo = () => {
1518
+ if (batchUndoRef.current !== null) setBatchUndoState(null);
1519
+ };
1520
+
1262
1521
  // Session + workspace -------------------------------------------------
1263
1522
  useEffect(() => {
1264
1523
  let disposed = false;
@@ -1335,6 +1594,24 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1335
1594
  toastTimerRef.current = setTimeout(() => setToast(null), action === undefined || action === null ? 1800 : 5000);
1336
1595
  };
1337
1596
 
1597
+ const resetGeometry = () => {
1598
+ setUi((previous) => clampUi({
1599
+ ...previous,
1600
+ x: null,
1601
+ y: null,
1602
+ width: DEFAULT_W,
1603
+ height: DEFAULT_H,
1604
+ }));
1605
+ showToast(t('uiReset'));
1606
+ };
1607
+
1608
+ useEffect(() => {
1609
+ resetHandlers.add(resetGeometry);
1610
+ return () => {
1611
+ resetHandlers.delete(resetGeometry);
1612
+ };
1613
+ }, []);
1614
+
1338
1615
  const scheduleSave = () => {
1339
1616
  clearTimer();
1340
1617
  timerRef.current = setTimeout(() => {
@@ -1378,6 +1655,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1378
1655
  saveNowRef.current = saveNow;
1379
1656
 
1380
1657
  const commitSegments = (next, options = {}) => {
1658
+ invalidateBatchUndo();
1381
1659
  segmentsRef.current = next;
1382
1660
  setSegments(next);
1383
1661
  pruneSelectionFor(next);
@@ -1396,11 +1674,20 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1396
1674
  segmentsRef.current = [];
1397
1675
  setSegments([]);
1398
1676
  setSelection(new Set());
1677
+ setSegmentUndo(new Map());
1678
+ setBatchUndoState(null);
1679
+ setTitleEditing('');
1680
+ setPreviewImage(null);
1681
+ setDeleteConfirm('');
1682
+ pendingScrollRef.current = null;
1399
1683
  setStatus('idle');
1400
1684
  setError('');
1401
1685
  return undefined;
1402
1686
  }
1403
- if (dirtyRef.current && rootRef.current !== '' && rootRef.current !== binding.cwd) {
1687
+ if (dirtyRef.current && rootRef.current !== '') {
1688
+ // Flush the old workspace/session note before this binding replaces
1689
+ // the in-memory segments; a same-workspace session switch must not
1690
+ // drop a still-debounced edit.
1404
1691
  saveNowRef.current?.({ force: true });
1405
1692
  }
1406
1693
  clearTimer();
@@ -1412,6 +1699,12 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1412
1699
  segmentsRef.current = [];
1413
1700
  setSegments([]);
1414
1701
  setSelection(new Set());
1702
+ setSegmentUndo(new Map());
1703
+ setBatchUndoState(null);
1704
+ setTitleEditing('');
1705
+ setPreviewImage(null);
1706
+ setDeleteConfirm('');
1707
+ pendingScrollRef.current = null;
1415
1708
  setStatus('loading');
1416
1709
  setError('');
1417
1710
  const load = () => {
@@ -1430,6 +1723,8 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1430
1723
  segmentsRef.current = value;
1431
1724
  setSegments(value);
1432
1725
  setSelection(new Set());
1726
+ setSegmentUndo(new Map());
1727
+ setBatchUndoState(null);
1433
1728
  versionRef.current = typeof data.mtimeMs === 'number' ? data.mtimeMs : null;
1434
1729
  setSavedAt(typeof data.mtimeMs === 'number' ? data.mtimeMs : null);
1435
1730
  dirtyRef.current = false;
@@ -1566,14 +1861,36 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1566
1861
  autoGrow(element);
1567
1862
  }, [segments]);
1568
1863
 
1864
+ // Restore a captured scroll offset right after a refresh commits ------
1865
+ useLayoutEffect(() => {
1866
+ const pending = pendingScrollRef.current;
1867
+ if (pending === null || pending === undefined) return;
1868
+ pendingScrollRef.current = null;
1869
+ const list = listRef.current;
1870
+ if (list === null || list === undefined) return;
1871
+ try {
1872
+ list.scrollTop = pending;
1873
+ } catch {
1874
+ /* A detached list cannot be scrolled. */
1875
+ }
1876
+ }, [segments]);
1877
+
1569
1878
  // Keyboard ------------------------------------------------------------
1570
1879
  useEffect(() => {
1571
1880
  const onKeyDown = (event) => {
1572
1881
  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 }));
1882
+ if (!chord || !event.shiftKey || event.altKey) return;
1883
+ if (event.key === 'N' || event.key === 'n') {
1884
+ event.preventDefault();
1885
+ setUi((previous) => clampUi({ ...previous, hidden: !previous.hidden }));
1886
+ return;
1887
+ }
1888
+ if (!resetShortcutHandledByHost
1889
+ && (event.key === '0' || event.key === ')' || event.code === 'Digit0'
1890
+ || event.code === 'Numpad0' || event.keyCode === 48)) {
1891
+ event.preventDefault();
1892
+ resetGeometry();
1893
+ }
1577
1894
  };
1578
1895
  window.addEventListener('keydown', onKeyDown, true);
1579
1896
  return () => {
@@ -1585,6 +1902,19 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1585
1902
  };
1586
1903
  }, []);
1587
1904
 
1905
+ // Close the preview / delete confirmation with Escape ----------------
1906
+ useEffect(() => {
1907
+ if (previewImage === null && deleteConfirm === '') return undefined;
1908
+ const onKeyDown = (event) => {
1909
+ if (event.key !== 'Escape') return;
1910
+ event.preventDefault();
1911
+ setPreviewImage(null);
1912
+ setDeleteConfirm('');
1913
+ };
1914
+ window.addEventListener('keydown', onKeyDown, true);
1915
+ return () => window.removeEventListener('keydown', onKeyDown, true);
1916
+ }, [previewImage, deleteConfirm]);
1917
+
1588
1918
  // Flush on page hide / reload -----------------------------------------
1589
1919
  useEffect(() => {
1590
1920
  const flush = () => {
@@ -1616,10 +1946,25 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1616
1946
  const active = typeof document !== 'undefined' ? document.activeElement : null;
1617
1947
  if (active !== null && active !== undefined && typeof active.closest === 'function'
1618
1948
  && active.closest('[data-dsh-notepad="true"]') !== null) return;
1949
+ // Snapshot the scroll offset right before the refresh; the list may
1950
+ // re-render (and jump) while the updated note is applied.
1951
+ const list = listRef.current;
1952
+ const scrollTop = list !== null && list !== undefined && Number.isFinite(list.scrollTop)
1953
+ ? list.scrollTop
1954
+ : null;
1955
+ const mySeq = ++refreshSeqRef.current;
1956
+ pendingScrollRef.current = scrollTop;
1619
1957
  fetchNote(root, sessionId)
1620
1958
  .then((data) => {
1621
- if (rootRef.current !== root || dirtyRef.current) return;
1622
- if (typeof data.mtimeMs === 'number' && data.mtimeMs === versionRef.current) return;
1959
+ if (mySeq !== refreshSeqRef.current) return;
1960
+ if (rootRef.current !== root || dirtyRef.current) {
1961
+ pendingScrollRef.current = null;
1962
+ return;
1963
+ }
1964
+ if (typeof data.mtimeMs === 'number' && data.mtimeMs === versionRef.current) {
1965
+ pendingScrollRef.current = null;
1966
+ return;
1967
+ }
1623
1968
  let incoming = normalizeServerSegments(data);
1624
1969
  if (isLegacyPayload(data)) incoming = incoming.slice().reverse();
1625
1970
  if (incoming.length === 0) incoming = [newSegment('')];
@@ -1627,11 +1972,15 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1627
1972
  segmentsRef.current = value;
1628
1973
  setSegments(value);
1629
1974
  pruneSelectionFor(value);
1975
+ setSegmentUndo(new Map());
1976
+ setBatchUndoState(null);
1630
1977
  versionRef.current = typeof data.mtimeMs === 'number' ? data.mtimeMs : null;
1631
1978
  setSavedAt(typeof data.mtimeMs === 'number' ? data.mtimeMs : null);
1632
1979
  setStatus('saved');
1633
1980
  })
1634
- .catch(() => {});
1981
+ .catch(() => {
1982
+ if (mySeq === refreshSeqRef.current) pendingScrollRef.current = null;
1983
+ });
1635
1984
  };
1636
1985
  const onVisibility = () => {
1637
1986
  if (typeof document !== 'undefined' && document.visibilityState === 'visible') refresh();
@@ -1650,9 +1999,17 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1650
1999
 
1651
2000
  // Segment mutations ---------------------------------------------------
1652
2001
  const updateSegmentText = (id, value) => {
2002
+ invalidateBatchUndo();
1653
2003
  const next = segmentsRef.current.map((segment) => (segment.id === id ? { ...segment, text: value } : segment));
1654
2004
  segmentsRef.current = next;
1655
2005
  setSegments(next);
2006
+ setSegmentUndo((previous) => {
2007
+ const record = previous.get(id);
2008
+ if (record === undefined || record.after === value) return previous;
2009
+ const pruned = new Map(previous);
2010
+ pruned.delete(id);
2011
+ return pruned;
2012
+ });
1656
2013
  dirtyRef.current = true;
1657
2014
  setStatus('dirty');
1658
2015
  setError('');
@@ -1663,6 +2020,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1663
2020
  const title = String(value ?? '').slice(0, MAX_TITLE_CHARS);
1664
2021
  const target = segmentsRef.current.find((segment) => segment.id === id);
1665
2022
  if (target === undefined || target.title === title) return;
2023
+ invalidateBatchUndo();
1666
2024
  const next = segmentsRef.current.map((segment) => (segment.id === id ? { ...segment, title } : segment));
1667
2025
  segmentsRef.current = next;
1668
2026
  setSegments(next);
@@ -1785,6 +2143,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1785
2143
  const stillReferenced = next.some((segment) =>
1786
2144
  (Array.isArray(segment.images) ? segment.images : []).some((image) => image.path === pathValue));
1787
2145
  commitSegments(next);
2146
+ if (previewImage !== null && previewImage.path === pathValue) setPreviewImage(null);
1788
2147
  if (!stillReferenced) deleteImageFile(pathValue);
1789
2148
  };
1790
2149
 
@@ -1815,7 +2174,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1815
2174
  const files = clipboardImageFiles(data);
1816
2175
  if (files.length === 0) return;
1817
2176
  event.preventDefault?.();
1818
- event.stopPropagation?.();
2177
+ event?.stopPropagation?.();
1819
2178
  attachImages(id, files, 'paste');
1820
2179
  };
1821
2180
 
@@ -1824,7 +2183,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1824
2183
  setDragSegment('');
1825
2184
  if (files.length === 0) return;
1826
2185
  event.preventDefault?.();
1827
- event.stopPropagation?.();
2186
+ event?.stopPropagation?.();
1828
2187
  attachImages(id, files, 'pick');
1829
2188
  };
1830
2189
 
@@ -1844,11 +2203,15 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1844
2203
  };
1845
2204
 
1846
2205
  const openImage = (image) => {
2206
+ setPreviewImage({ path: image.path, name: image.name });
2207
+ };
2208
+
2209
+ const openOriginalImage = (image) => {
1847
2210
  const url = imageUrl(image.path, rootRef.current);
1848
2211
  try {
1849
2212
  window.open(url, '_blank', 'noopener,noreferrer');
1850
2213
  } catch {
1851
- /* Popups may be blocked; the path remains available in the title. */
2214
+ /* Popups may be blocked; the in-panel preview still works. */
1852
2215
  }
1853
2216
  };
1854
2217
 
@@ -1935,6 +2298,8 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1935
2298
  if (record === null || record === undefined) return;
1936
2299
  undoRef.current = null;
1937
2300
  clearPendingImageDeletes();
2301
+ setSegmentUndo(new Map());
2302
+ setBatchUndoState(null);
1938
2303
  commitSegments(record.snapshot);
1939
2304
  setToast(null);
1940
2305
  };
@@ -1949,6 +2314,28 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1949
2314
  showToast(t('deletedToast'), { label: t('undo'), onClick: undoLast });
1950
2315
  };
1951
2316
 
2317
+ /** Irreversible delete for an active segment, gated by a confirmation. */
2318
+ const deleteActiveSegment = (id) => {
2319
+ setDeleteConfirm('');
2320
+ setPreviewImage(null);
2321
+ const target = segmentsRef.current.find((segment) => segment.id === id && segment.archived !== true);
2322
+ if (target === undefined) return;
2323
+ // This delete cannot be undone: retire any older snackbar undo so it
2324
+ // cannot resurrect the segment through a stale snapshot.
2325
+ undoRef.current = null;
2326
+ setToast(null);
2327
+ setSegmentUndo((previous) => {
2328
+ if (!previous.has(id)) return previous;
2329
+ const updated = new Map(previous);
2330
+ updated.delete(id);
2331
+ return updated;
2332
+ });
2333
+ const next = segmentsRef.current.filter((segment) => segment.id !== id);
2334
+ commitSegments(next);
2335
+ scheduleImageCleanup(target.images);
2336
+ showToast(t('deletedToast'));
2337
+ };
2338
+
1952
2339
  const clearArchive = () => {
1953
2340
  const archived = segmentsRef.current.filter((segment) => segment.archived === true);
1954
2341
  if (archived.length === 0) return;
@@ -2046,7 +2433,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2046
2433
  const types = event?.dataTransfer?.types;
2047
2434
  if (types !== undefined && types !== null && !Array.from(types).includes('application/x-dsh-notepad-segment')) return;
2048
2435
  event.preventDefault?.();
2049
- event.stopPropagation?.();
2436
+ event?.stopPropagation?.();
2050
2437
  const rect = typeof event.currentTarget?.getBoundingClientRect === 'function'
2051
2438
  ? event.currentTarget.getBoundingClientRect()
2052
2439
  : null;
@@ -2071,7 +2458,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2071
2458
  setReorderTarget(null);
2072
2459
  if (source === '') return;
2073
2460
  event.preventDefault?.();
2074
- event.stopPropagation?.();
2461
+ event?.stopPropagation?.();
2075
2462
  reorderSegments(source, id, target?.after === true);
2076
2463
  };
2077
2464
 
@@ -2084,14 +2471,33 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2084
2471
  setOptimizing(next);
2085
2472
  };
2086
2473
 
2474
+ /** Cache key: the prompt depends on the workspace, title and card text. */
2475
+ const optimizationCacheKey = (root, segment) =>
2476
+ `${root}\u0000${segment.title ?? ''}\u0000${segment.text}`;
2477
+
2478
+ const rememberOptimization = (key, value) => {
2479
+ const cache = optimizeCacheRef.current;
2480
+ cache.delete(key);
2481
+ cache.set(key, value);
2482
+ while (cache.size > OPTIMIZE_CACHE_LIMIT) {
2483
+ const oldest = cache.keys().next();
2484
+ if (oldest.done === true) break;
2485
+ cache.delete(oldest.value);
2486
+ }
2487
+ };
2488
+
2087
2489
  const requestOptimization = async (segment) => {
2088
2490
  const root = rootRef.current;
2089
2491
  const sessionId = rootSessionRef.current;
2090
2492
  if (root === '' || sessionId === '') throw new Error(t('optimizeUnavailable'));
2091
2493
  if (segment.text.length > MAX_OPTIMIZE_CHARS) throw new Error(t('optimizeTooLong'));
2494
+ const cacheKey = optimizationCacheKey(root, segment);
2495
+ const cached = optimizeCacheRef.current.get(cacheKey);
2496
+ if (typeof cached === 'string' && cached !== '') return cached;
2092
2497
  const data = await optimizeNoteText(root, sessionId, segment.text, segment.title ?? '');
2093
2498
  const value = typeof data.text === 'string' ? data.text.trim() : '';
2094
2499
  if (value === '') throw new Error(t('optimizeFailed'));
2500
+ rememberOptimization(cacheKey, value);
2095
2501
  return value;
2096
2502
  };
2097
2503
 
@@ -2102,18 +2508,21 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2102
2508
  showToast(t('optimizeEmpty'));
2103
2509
  return;
2104
2510
  }
2105
- const snapshot = segmentsRef.current;
2106
2511
  const original = target.text;
2107
2512
  markOptimizing(id, true);
2108
2513
  try {
2109
2514
  const value = await requestOptimization(target);
2110
2515
  const current = segmentsRef.current.find((segment) => segment.id === id);
2111
2516
  if (current === undefined || current.text !== original || value === original) return;
2112
- undoRef.current = { snapshot };
2113
2517
  const next = segmentsRef.current.map((segment) =>
2114
2518
  (segment.id === id ? { ...segment, text: value } : segment));
2115
2519
  commitSegments(next);
2116
- showToast(t('optimizedToast'), { label: t('undo'), onClick: undoLast });
2520
+ setSegmentUndo((previous) => {
2521
+ const updated = new Map(previous);
2522
+ updated.set(id, { before: original, after: value });
2523
+ return updated;
2524
+ });
2525
+ showToast(t('optimizedToast'));
2117
2526
  } catch (reason) {
2118
2527
  const message = String(reason?.message ?? reason);
2119
2528
  showToast(message === t('optimizeTooLong') || message === t('optimizeUnavailable')
@@ -2124,20 +2533,46 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2124
2533
  }
2125
2534
  };
2126
2535
 
2127
- const optimizeAllSegments = async () => {
2128
- if (optimizeAllRef.current) return;
2536
+ const undoOptimizeSegment = (id) => {
2537
+ const record = segmentUndo.get(id);
2538
+ const current = segmentsRef.current.find((segment) => segment.id === id);
2539
+ if (record !== undefined && current !== undefined && current.text === record.after) {
2540
+ const next = segmentsRef.current.map((segment) =>
2541
+ (segment.id === id ? { ...segment, text: record.before } : segment));
2542
+ commitSegments(next);
2543
+ setSegmentUndo((previous) => {
2544
+ if (!previous.has(id)) return previous;
2545
+ const updated = new Map(previous);
2546
+ updated.delete(id);
2547
+ return updated;
2548
+ });
2549
+ showToast(t('optimizeUndone'));
2550
+ return;
2551
+ }
2552
+ setSegmentUndo((previous) => {
2553
+ if (!previous.has(id)) return previous;
2554
+ const updated = new Map(previous);
2555
+ updated.delete(id);
2556
+ return updated;
2557
+ });
2558
+ };
2559
+
2560
+ /** Improve only the checked segments; identical text is answered from cache. */
2561
+ const optimizeSelectedSegments = async () => {
2562
+ if (optimizeBatchRef.current) return;
2563
+ const ids = new Set(selectedRef.current);
2129
2564
  const targets = segmentsRef.current.filter((segment) =>
2130
- segment.archived !== true && segment.text.trim() !== '');
2565
+ segment.archived !== true && ids.has(segment.id) && segment.text.trim() !== '');
2131
2566
  if (targets.length === 0) {
2132
2567
  showToast(t('optimizeEmpty'));
2133
2568
  return;
2134
2569
  }
2135
- optimizeAllRef.current = true;
2570
+ optimizeBatchRef.current = true;
2136
2571
  const snapshot = segmentsRef.current;
2137
2572
  let done = 0;
2138
2573
  let changed = 0;
2139
2574
  let failed = 0;
2140
- setOptimizeAll({ done, total: targets.length });
2575
+ setOptimizeBatch({ done, total: targets.length });
2141
2576
  try {
2142
2577
  for (const target of targets) {
2143
2578
  const current = segmentsRef.current.find((segment) =>
@@ -2152,6 +2587,11 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2152
2587
  ? { ...segment, text: value }
2153
2588
  : segment));
2154
2589
  commitSegments(next);
2590
+ setSegmentUndo((previous) => {
2591
+ const updated = new Map(previous);
2592
+ updated.set(target.id, { before: current.text, after: value });
2593
+ return updated;
2594
+ });
2155
2595
  changed += 1;
2156
2596
  }
2157
2597
  } catch {
@@ -2161,20 +2601,29 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2161
2601
  }
2162
2602
  }
2163
2603
  done += 1;
2164
- setOptimizeAll({ done, total: targets.length });
2604
+ setOptimizeBatch({ done, total: targets.length });
2165
2605
  }
2166
2606
  if (changed > 0) {
2167
- undoRef.current = { snapshot };
2168
- showToast(t('optimizedCount', { n: changed }), { label: t('undo'), onClick: undoLast });
2607
+ setBatchUndoState({ snapshot });
2608
+ showToast(t('optimizedCount', { n: changed }));
2169
2609
  } else {
2170
2610
  showToast(failed > 0 ? t('optimizeFailed') : t('optimizedToast'));
2171
2611
  }
2172
2612
  } finally {
2173
- optimizeAllRef.current = false;
2174
- setOptimizeAll(null);
2613
+ optimizeBatchRef.current = false;
2614
+ setOptimizeBatch(null);
2175
2615
  }
2176
2616
  };
2177
2617
 
2618
+ const undoBatchOptimization = () => {
2619
+ const record = batchUndoRef.current;
2620
+ if (record === null || record === undefined) return;
2621
+ setBatchUndoState(null);
2622
+ setSegmentUndo(new Map());
2623
+ commitSegments(record.snapshot);
2624
+ showToast(t('optimizeUndone'));
2625
+ };
2626
+
2178
2627
  // Selection -----------------------------------------------------------
2179
2628
  const toggleSelected = (id) => {
2180
2629
  const next = new Set(selectedRef.current);
@@ -2232,6 +2681,28 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2232
2681
  showToast(copied ? t('insertUnavailable') : t('copyFailed'));
2233
2682
  };
2234
2683
 
2684
+ /** Start a new session in this workspace with the text already in its composer. */
2685
+ const insertNewSession = async (list) => {
2686
+ const value = mergedText(list);
2687
+ if (value === '') {
2688
+ showToast(t('insertEmpty'));
2689
+ return;
2690
+ }
2691
+ const uiWorkspace = ctx.get('uiWorkspace');
2692
+ if (uiWorkspace !== undefined && uiWorkspace !== null && typeof uiWorkspace.startSession === 'function') {
2693
+ try {
2694
+ if (dirtyRef.current) await saveNowRef.current?.({ force: true });
2695
+ uiWorkspace.startSession(undefined, { prompt: value, clearPreviousDraft: true });
2696
+ showToast(t('insertNewDone'));
2697
+ return;
2698
+ } catch {
2699
+ /* Fall through to copy. */
2700
+ }
2701
+ }
2702
+ const copied = await copyText(value);
2703
+ showToast(copied ? t('insertNewUnavailable') : t('copyFailed'));
2704
+ };
2705
+
2235
2706
  const replaceSegments = async (list) => {
2236
2707
  const value = mergedText(list);
2237
2708
  if (value === '') {
@@ -2246,55 +2717,93 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2246
2717
  showToast(copied ? t('replaceUnavailable') : t('copyFailed'));
2247
2718
  };
2248
2719
 
2720
+ /** Resolve queue vs steer exactly like the composer's Send button. */
2721
+ const resolveSendMode = (sessionId) => {
2722
+ try {
2723
+ const sessions = ctx.get('sessions');
2724
+ const row = sessions?.list?.getSnapshot?.()?.byId?.[sessionId];
2725
+ if (row?.running !== true) return 'queue';
2726
+ } catch {
2727
+ return 'queue';
2728
+ }
2729
+ return busyEnterPreference(ctx);
2730
+ };
2731
+
2732
+ /** Send through the session face with an explicit queue/steer mode. */
2733
+ const promptSession = async (sessionId, value, mode) => {
2734
+ const sessions = ctx.get('sessions');
2735
+ if (sessions === undefined || sessions === null || typeof sessions.binding !== 'function') return false;
2736
+ const liveBinding = sessions.binding(sessionId);
2737
+ const session = liveBinding?.session;
2738
+ if (session === undefined || session === null || typeof session.prompt !== 'function') return false;
2739
+ let echo;
2740
+ try {
2741
+ echo = typeof session.beginSubmission === 'function'
2742
+ ? session.beginSubmission({ mode, text: value, attachments: [] })
2743
+ : undefined;
2744
+ } catch {
2745
+ echo = undefined;
2746
+ }
2747
+ try {
2748
+ const result = await session.prompt([{ type: 'text', text: value }], mode, undefined, echo?.requestId);
2749
+ if (result !== undefined && result !== null && result.ok === false) {
2750
+ const failure = new Error(result.error?.message ?? 'prompt failed');
2751
+ failure.code = result.error?.code;
2752
+ throw failure;
2753
+ }
2754
+ return true;
2755
+ } catch (reason) {
2756
+ try {
2757
+ echo?.abandon?.();
2758
+ } catch {
2759
+ /* ignore */
2760
+ }
2761
+ throw reason;
2762
+ }
2763
+ };
2764
+
2249
2765
  /**
2250
- * Send text into the current session. Prefers the documented scoped
2251
- * conversation service; falls back to the session face with a local echo.
2766
+ * Send text into a session. Queue prefers the documented scoped conversation
2767
+ * service; steer and its fallback go through the session face. The delivery
2768
+ * mode follows the General Settings "send while busy" preference.
2252
2769
  * @returns {Promise<boolean>} whether a prompt was accepted
2253
2770
  */
2254
- const sendToSession = async (value) => {
2255
- const sessionId = rootSessionRef.current !== '' ? rootSessionRef.current : bindingRef.current?.id;
2771
+ const sendToSession = async (value, targetSessionId) => {
2772
+ const sessionId = typeof targetSessionId === 'string' && targetSessionId !== ''
2773
+ ? targetSessionId
2774
+ : (rootSessionRef.current !== '' ? rootSessionRef.current : bindingRef.current?.id);
2256
2775
  if (typeof sessionId !== 'string' || sessionId === '') return false;
2257
2776
  const sessions = ctx.get('sessions');
2258
2777
  if (sessions === undefined || sessions === null) return false;
2778
+ const mode = resolveSendMode(sessionId);
2779
+ let scoped;
2259
2780
  if (typeof sessions.scope === 'function') {
2260
- const scoped = sessions.scope(sessionId);
2261
- const conversation = scoped === undefined || scoped === null
2262
- ? undefined
2263
- : typeof scoped.get === 'function' ? scoped.get('conversation') : scoped.conversation;
2264
- if (conversation !== undefined && conversation !== null && typeof conversation.send === 'function') {
2265
- await conversation.send(value);
2266
- return true;
2781
+ try {
2782
+ scoped = sessions.scope(sessionId);
2783
+ } catch {
2784
+ scoped = undefined;
2267
2785
  }
2268
2786
  }
2269
- if (typeof sessions.binding === 'function') {
2270
- const liveBinding = sessions.binding(sessionId);
2271
- const session = liveBinding?.session;
2272
- if (session !== undefined && session !== null && typeof session.prompt === 'function') {
2273
- let echo;
2274
- try {
2275
- echo = typeof session.beginSubmission === 'function'
2276
- ? session.beginSubmission({ mode: 'queue', text: value, attachments: [] })
2277
- : undefined;
2278
- } catch {
2279
- echo = undefined;
2280
- }
2281
- try {
2282
- const result = await session.prompt([{ type: 'text', text: value }], 'queue', undefined, echo?.requestId);
2283
- if (result !== undefined && result !== null && result.ok === false) {
2284
- throw new Error(result.error?.message ?? 'prompt failed');
2285
- }
2787
+ const conversation = scoped === undefined || scoped === null
2788
+ ? undefined
2789
+ : typeof scoped.get === 'function' ? scoped.get('conversation') : scoped.conversation;
2790
+ if (mode === 'queue' && conversation !== undefined && conversation !== null
2791
+ && typeof conversation.send === 'function') {
2792
+ await conversation.send(value);
2793
+ return true;
2794
+ }
2795
+ try {
2796
+ return await promptSession(sessionId, value, mode);
2797
+ } catch (reason) {
2798
+ if (mode === 'steer' && reason?.code === 'session/steer-unavailable') {
2799
+ if (conversation !== undefined && conversation !== null && typeof conversation.send === 'function') {
2800
+ await conversation.send(value);
2286
2801
  return true;
2287
- } catch (reason) {
2288
- try {
2289
- echo?.abandon?.();
2290
- } catch {
2291
- /* ignore */
2292
- }
2293
- throw reason;
2294
2802
  }
2803
+ return promptSession(sessionId, value, 'queue');
2295
2804
  }
2805
+ throw reason;
2296
2806
  }
2297
- return false;
2298
2807
  };
2299
2808
 
2300
2809
  const sendSegments = async (list) => {
@@ -2335,8 +2844,78 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2335
2844
  }
2336
2845
  };
2337
2846
 
2847
+ /** Create an empty session in a workspace and return its id. */
2848
+ const createWorkspaceSession = async (cwd) => {
2849
+ const sessions = ctx.get('sessions');
2850
+ if (sessions === undefined || sessions === null || typeof sessions.create !== 'function') return null;
2851
+ const result = await sessions.create({ cwd });
2852
+ if (result === undefined || result === null) return null;
2853
+ if (result.ok === false) {
2854
+ const failure = new Error(result.error?.message ?? 'create session failed');
2855
+ failure.code = result.error?.code;
2856
+ throw failure;
2857
+ }
2858
+ const id = result.value?.sessionId ?? result.sessionId
2859
+ ?? (typeof result === 'string' ? result : undefined);
2860
+ return typeof id === 'string' && id !== '' ? id : null;
2861
+ };
2862
+
2863
+ /** Start a fresh session, send the text there, then switch to it. */
2864
+ const sendNewSession = async (list) => {
2865
+ if (sendingRef.current) return;
2866
+ const value = mergedText(list);
2867
+ if (value === '') {
2868
+ showToast(t('insertEmpty'));
2869
+ return;
2870
+ }
2871
+ const cwd = bindingRef.current?.cwd ?? rootRef.current;
2872
+ if (cwd === '') {
2873
+ showToast(t('sendNewUnavailable'));
2874
+ return;
2875
+ }
2876
+ sendingRef.current = true;
2877
+ setSending(true);
2878
+ try {
2879
+ const newSessionId = await createWorkspaceSession(cwd);
2880
+ if (newSessionId === null) {
2881
+ if (!insertValue(value)) await copyText(value);
2882
+ showToast(t('sendNewUnavailable'));
2883
+ return;
2884
+ }
2885
+ const accepted = await sendToSession(value, newSessionId);
2886
+ if (accepted !== true) {
2887
+ if (!insertValue(value)) await copyText(value);
2888
+ showToast(t('sendUnavailable'));
2889
+ return;
2890
+ }
2891
+ showToast(t('sendNewDone'));
2892
+ const ids = new Set(
2893
+ list.filter((segment) => segment.archived !== true).map((segment) => segment.id),
2894
+ );
2895
+ if (ids.size > 0 && (rootRef.current === '' || rootRef.current === cwd)) {
2896
+ const archivedAt = new Date().toISOString();
2897
+ const next = segmentsRef.current.map((segment) =>
2898
+ (ids.has(segment.id) ? { ...segment, archived: true, archivedAt } : segment));
2899
+ setSelection(new Set([...selectedRef.current].filter((id) => !ids.has(id))));
2900
+ commitSegments(next);
2901
+ await saveNowRef.current?.({ force: true });
2902
+ }
2903
+ const uiWorkspace = ctx.get('uiWorkspace');
2904
+ if (uiWorkspace !== undefined && uiWorkspace !== null && typeof uiWorkspace.openSession === 'function') {
2905
+ uiWorkspace.openSession(newSessionId);
2906
+ }
2907
+ } catch (reason) {
2908
+ showToast(`${t('sendFailed')}: ${String(reason?.message ?? reason)}`);
2909
+ } finally {
2910
+ sendingRef.current = false;
2911
+ setSending(false);
2912
+ }
2913
+ };
2914
+
2338
2915
  const copyAll = async () => {
2339
- const value = mergedText(segmentsRef.current);
2916
+ // Copy only active work: archived segments were already sent or filed
2917
+ // away and must not leak back into a copy-all.
2918
+ const value = mergedText(segmentsRef.current.filter((segment) => segment.archived !== true));
2340
2919
  if (value === '') {
2341
2920
  showToast(t('insertEmpty'));
2342
2921
  return;
@@ -2370,6 +2949,34 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2370
2949
  updateSegmentText(id, event.target?.value ?? '');
2371
2950
  };
2372
2951
 
2952
+ /** Focus a card's textarea and place the caret at an absolute offset. */
2953
+ const focusSegmentAt = (id, caret) => {
2954
+ const element = textareaRefs.current.get(id);
2955
+ if (element === null || element === undefined) return false;
2956
+ try {
2957
+ element.focus?.({ preventScroll: true });
2958
+ } catch {
2959
+ try {
2960
+ element.focus?.();
2961
+ } catch {
2962
+ /* ignore */
2963
+ }
2964
+ }
2965
+ try {
2966
+ const value = typeof element.value === 'string' ? element.value : '';
2967
+ const position = Math.max(0, Math.min(Number(caret) || 0, value.length));
2968
+ if (typeof element.setSelectionRange === 'function') element.setSelectionRange(position, position);
2969
+ } catch {
2970
+ /* A non-layout shim may not support ranges. */
2971
+ }
2972
+ try {
2973
+ element.scrollIntoView?.({ block: 'nearest' });
2974
+ } catch {
2975
+ /* ignore */
2976
+ }
2977
+ return true;
2978
+ };
2979
+
2373
2980
  const onSegmentKeyDown = (event, id) => {
2374
2981
  if (event.isComposing === true || event.nativeEvent?.isComposing === true) return;
2375
2982
  const chord = event.ctrlKey || event.metaKey;
@@ -2384,6 +2991,28 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2384
2991
  insertSegments(list);
2385
2992
  return;
2386
2993
  }
2994
+ if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
2995
+ if (event.altKey || event.shiftKey || chord) return;
2996
+ const element = event.currentTarget ?? event.target;
2997
+ const value = typeof element?.value === 'string' ? element.value : '';
2998
+ const start = typeof element?.selectionStart === 'number' ? element.selectionStart : null;
2999
+ const end = typeof element?.selectionEnd === 'number' ? element.selectionEnd : start;
3000
+ if (start === null || start !== end) return;
3001
+ const firstBreak = value.indexOf('\n');
3002
+ const lastBreak = value.lastIndexOf('\n');
3003
+ const up = event.key === 'ArrowUp';
3004
+ const atEdge = up
3005
+ ? (firstBreak === -1 || start <= firstBreak)
3006
+ : (lastBreak === -1 || start > lastBreak);
3007
+ if (!atEdge) return;
3008
+ const active = segmentsRef.current.filter((segment) => segment.archived !== true);
3009
+ const position = active.findIndex((segment) => segment.id === id);
3010
+ const neighbor = position < 0 ? undefined : active[position + (up ? -1 : 1)];
3011
+ if (neighbor !== undefined && focusSegmentAt(neighbor.id, up ? neighbor.text.length : 0)) {
3012
+ event.preventDefault?.();
3013
+ }
3014
+ return;
3015
+ }
2387
3016
  if (event.key !== 'Enter' || event.shiftKey || event.altKey || chord) return;
2388
3017
  event.preventDefault();
2389
3018
  splitSegment(id, event.currentTarget ?? event.target);
@@ -2400,8 +3029,11 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2400
3029
  const vw = window.innerWidth || 1280;
2401
3030
  const vh = window.innerHeight || 800;
2402
3031
  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));
3032
+ const minY = hostChromeTop() + EDGE_GAP;
3033
+ const x = Math.min(Math.max(EDGE_GAP, start.x + moveEvent.clientX - start.px),
3034
+ Math.max(EDGE_GAP, vw - ui.width - EDGE_GAP));
3035
+ const y = Math.min(Math.max(minY, start.y + moveEvent.clientY - start.py),
3036
+ Math.max(minY, vh - height - EDGE_GAP));
2405
3037
  setUi((previous) => ({ ...previous, x, y }));
2406
3038
  };
2407
3039
  const stop = () => {
@@ -2420,24 +3052,27 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2420
3052
  if (ui.minimized) return;
2421
3053
  if (event.button !== undefined && event.button !== 0) return;
2422
3054
  event.preventDefault?.();
2423
- event.stopPropagation?.();
3055
+ event?.stopPropagation?.();
2424
3056
  const start = { px: event.clientX, py: event.clientY, width: ui.width, height: ui.height };
2425
3057
  resizeRef.current = start;
2426
3058
  setResizing(true);
2427
3059
  const move = (moveEvent) => {
2428
3060
  const vw = window.innerWidth || 1280;
2429
3061
  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));
3062
+ const minY = hostChromeTop() + EDGE_GAP;
3063
+ const width = Math.min(Math.max(MIN_W, start.width + moveEvent.clientX - start.px),
3064
+ Math.max(MIN_W, vw - EDGE_GAP * 2));
3065
+ const height = Math.min(Math.max(MIN_H, start.height + moveEvent.clientY - start.py),
3066
+ Math.max(MIN_H, vh - minY - EDGE_GAP));
2432
3067
  setUi((previous) => {
2433
- const maxX = Math.max(8, vw - width - 8);
2434
- const maxY = Math.max(8, vh - height - 8);
3068
+ const maxX = Math.max(EDGE_GAP, vw - width - EDGE_GAP);
3069
+ const maxY = Math.max(minY, vh - height - EDGE_GAP);
2435
3070
  return {
2436
3071
  ...previous,
2437
3072
  width,
2438
3073
  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,
3074
+ x: typeof previous.x === 'number' ? Math.min(Math.max(EDGE_GAP, previous.x), maxX) : previous.x,
3075
+ y: typeof previous.y === 'number' ? Math.min(Math.max(minY, previous.y), maxY) : previous.y,
2441
3076
  };
2442
3077
  });
2443
3078
  };
@@ -2463,10 +3098,12 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2463
3098
  const allSelected = activeSegments.length > 0 && selectedCount === activeSegments.length;
2464
3099
  const unsentCount = countUnsent(segments);
2465
3100
  const totalChars = segments.reduce((sum, segment) => sum + segment.text.length, 0);
3101
+ const activeChars = activeSegments.reduce((sum, segment) => sum + segment.text.length, 0);
2466
3102
  const displayWidth = ui.width;
2467
3103
  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);
3104
+ const fallbackX = Math.max(EDGE_GAP, ((typeof window !== 'undefined' && window.innerWidth) || 1280) - displayWidth - 24);
3105
+ const fallbackY = Math.max(hostChromeTop() + EDGE_GAP,
3106
+ ((typeof window !== 'undefined' && window.innerHeight) || 800) - displayHeight - 24);
2470
3107
  const left = typeof ui.x === 'number' ? ui.x : fallbackX;
2471
3108
  const top = typeof ui.y === 'number' ? ui.y : fallbackY;
2472
3109
 
@@ -2489,6 +3126,17 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2489
3126
  h('span', { className: 'dsh-np-title' }, t('title')),
2490
3127
  h('span', { className: 'dsh-np-workspace', title: binding.cwd }, workspaceName(binding.cwd)),
2491
3128
  h('span', { className: 'dsh-np-spacer' }),
3129
+ h(
3130
+ 'button',
3131
+ {
3132
+ type: 'button',
3133
+ className: 'dsh-np-btn',
3134
+ title: `${t('resetShortcut')} · Ctrl+Shift+0`,
3135
+ 'aria-label': t('resetShortcut'),
3136
+ onClick: resetGeometry,
3137
+ },
3138
+ h(ResetIcon, { size: 15 }),
3139
+ ),
2492
3140
  h(
2493
3141
  'button',
2494
3142
  {
@@ -2496,7 +3144,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2496
3144
  className: 'dsh-np-btn',
2497
3145
  title: t('copy'),
2498
3146
  'aria-label': t('copy'),
2499
- disabled: totalChars === 0,
3147
+ disabled: activeChars === 0,
2500
3148
  onClick: copyAll,
2501
3149
  },
2502
3150
  h(CopyIcon, { size: 15 }),
@@ -2549,6 +3197,37 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2549
3197
  ),
2550
3198
  );
2551
3199
 
3200
+ /** One icon that expands into current-session + new-session actions. */
3201
+ const actionGroup = ({ size, primary, extra, disabled = false }) => {
3202
+ const base = size === 'bar' ? 'dsh-np-btn' : 'dsh-np-segbtn';
3203
+ const primaryProps = {
3204
+ type: 'button',
3205
+ className: base,
3206
+ title: primary.title,
3207
+ 'aria-label': primary.title,
3208
+ disabled,
3209
+ onClick: primary.onClick,
3210
+ };
3211
+ if (primary.accent === true) primaryProps['data-accent'] = 'true';
3212
+ return h(
3213
+ 'div',
3214
+ { className: 'dsh-np-actiongroup', 'data-size': size },
3215
+ 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
+ ),
3228
+ );
3229
+ };
3230
+
2552
3231
  const tools = h(
2553
3232
  'div',
2554
3233
  { className: 'dsh-np-tools' },
@@ -2566,18 +3245,19 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2566
3245
  selectedCount > 0 ? t('selected', { n: selectedCount }) : t('selectHint')),
2567
3246
  ),
2568
3247
  h('span', { className: 'dsh-np-spacer' }),
2569
- h(
2570
- 'button',
2571
- {
2572
- type: 'button',
2573
- className: 'dsh-np-btn',
3248
+ actionGroup({
3249
+ size: 'bar',
3250
+ disabled: selectedCount === 0,
3251
+ primary: {
2574
3252
  title: selectedCount > 0 ? t('insertSelected', { n: selectedCount }) : t('insert'),
2575
- 'aria-label': t('insert'),
2576
- disabled: selectedCount === 0,
2577
3253
  onClick: () => insertSegments(selectedActive),
3254
+ icon: h(InsertIcon, { size: 14 }),
2578
3255
  },
2579
- h(InsertIcon, { size: 14 }),
2580
- ),
3256
+ extra: {
3257
+ title: selectedCount > 0 ? t('insertNewSelected', { n: selectedCount }) : t('insertNewSession'),
3258
+ onClick: () => insertNewSession(selectedActive),
3259
+ },
3260
+ }),
2581
3261
  h(
2582
3262
  'button',
2583
3263
  {
@@ -2590,21 +3270,22 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2590
3270
  },
2591
3271
  h(ReplaceIcon, { size: 14 }),
2592
3272
  ),
2593
- h(
2594
- 'button',
2595
- {
2596
- type: 'button',
2597
- className: 'dsh-np-btn',
2598
- 'data-accent': 'true',
3273
+ actionGroup({
3274
+ size: 'bar',
3275
+ disabled: selectedCount === 0 || sending,
3276
+ primary: {
2599
3277
  title: selectedCount > 0
2600
3278
  ? (sending ? t('sending') : t('sendSelected', { n: selectedCount }))
2601
3279
  : t('send'),
2602
- 'aria-label': t('send'),
2603
- disabled: selectedCount === 0 || sending,
2604
3280
  onClick: () => sendSegments(selectedActive),
3281
+ icon: h(SendIcon, { size: 14 }),
3282
+ accent: true,
2605
3283
  },
2606
- h(SendIcon, { size: 14 }),
2607
- ),
3284
+ extra: {
3285
+ title: selectedCount > 0 ? t('sendNewSelected', { n: selectedCount }) : t('sendNewSession'),
3286
+ onClick: () => sendNewSession(selectedActive),
3287
+ },
3288
+ }),
2608
3289
  h(
2609
3290
  'button',
2610
3291
  {
@@ -2622,17 +3303,24 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2622
3303
  {
2623
3304
  type: 'button',
2624
3305
  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,
3306
+ 'data-active': optimizeBatch !== null || batchUndo !== null ? 'true' : 'false',
3307
+ title: optimizeBatch !== null
3308
+ ? t('optimizeAllProgress', { done: optimizeBatch.done, total: optimizeBatch.total })
3309
+ : batchUndo !== null
3310
+ ? t('undoOptimize')
3311
+ : selectedCount > 0
3312
+ ? t('optimizeSelected', { n: selectedCount })
3313
+ : t('optimizeSelectHint'),
3314
+ 'aria-label': batchUndo !== null
3315
+ ? t('undoOptimize')
3316
+ : selectedCount > 0 ? t('optimizeSelected', { n: selectedCount }) : t('optimizeSelectHint'),
3317
+ disabled: optimizeBatch !== null
3318
+ || (batchUndo === null && selectedCount === 0),
3319
+ onClick: batchUndo !== null ? undoBatchOptimization : optimizeSelectedSegments,
2632
3320
  },
2633
- optimizeAll !== null
3321
+ optimizeBatch !== null
2634
3322
  ? h('span', { className: 'dsh-np-mini-spinner' })
2635
- : h(SparkleIcon, { size: 14 }),
3323
+ : batchUndo !== null ? h(UndoIcon, { size: 14 }) : h(SparkleIcon, { size: 14 }),
2636
3324
  ),
2637
3325
  h(
2638
3326
  'button',
@@ -2720,6 +3408,15 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2720
3408
  const activeCards = activeSegments.map((segment, index) => {
2721
3409
  const images = Array.isArray(segment.images) ? segment.images : [];
2722
3410
  const empty = segment.text.trim() === '' && images.length === 0;
3411
+ const titleValue = segment.title ?? '';
3412
+ const showTitleInput = titleValue !== '' || titleEditing === segment.id;
3413
+ const undoRecord = segmentUndo.get(segment.id);
3414
+ const canUndoOptimize = undoRecord !== undefined && undoRecord.after === segment.text;
3415
+ const optimizingNow = optimizing.has(segment.id);
3416
+ const aiBusy = optimizingNow || optimizeBatch !== null;
3417
+ const aiTitle = aiBusy
3418
+ ? t('optimizing')
3419
+ : canUndoOptimize ? t('undoOptimize') : t('optimizeSegment');
2723
3420
  return h(
2724
3421
  'article',
2725
3422
  {
@@ -2786,28 +3483,42 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2786
3483
  },
2787
3484
  String(index + 1).padStart(2, '0'),
2788
3485
  ),
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
- }),
3486
+ showTitleInput
3487
+ ? h('input', {
3488
+ type: 'text',
3489
+ className: 'dsh-np-title-input',
3490
+ value: titleValue,
3491
+ placeholder: t('titlePlaceholder'),
3492
+ maxLength: MAX_TITLE_CHARS,
3493
+ autoFocus: titleValue === '',
3494
+ 'aria-label': t('segmentTitle'),
3495
+ spellCheck: false,
3496
+ onFocus: () => setTitleEditing(segment.id),
3497
+ onChange: (event) => updateSegmentTitle(segment.id, event.target?.value ?? ''),
3498
+ onKeyDown: (event) => {
3499
+ if (event.key !== 'Enter') return;
3500
+ event.preventDefault();
3501
+ try {
3502
+ event.currentTarget?.blur?.();
3503
+ } catch {
3504
+ /* ignore */
3505
+ }
3506
+ },
3507
+ onBlur: () => {
3508
+ if (dirtyRef.current) saveNowRef.current?.();
3509
+ if ((segment.title ?? '') === '') setTitleEditing('');
3510
+ },
3511
+ })
3512
+ : h(
3513
+ 'button',
3514
+ {
3515
+ type: 'button',
3516
+ className: 'dsh-np-title-add',
3517
+ title: t('segmentTitle'),
3518
+ 'aria-label': t('segmentTitle'),
3519
+ onClick: () => setTitleEditing(segment.id),
3520
+ },
3521
+ ),
2811
3522
  h(
2812
3523
  'button',
2813
3524
  {
@@ -2819,18 +3530,19 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2819
3530
  },
2820
3531
  h(ImageIcon, { size: 14 }),
2821
3532
  ),
2822
- h(
2823
- 'button',
2824
- {
2825
- type: 'button',
2826
- className: 'dsh-np-segbtn',
3533
+ actionGroup({
3534
+ size: 'card',
3535
+ disabled: empty,
3536
+ primary: {
2827
3537
  title: t('insertSegment'),
2828
- 'aria-label': t('insertSegment'),
2829
- disabled: empty,
2830
3538
  onClick: () => insertSegments([segment]),
3539
+ icon: h(InsertIcon, { size: 14 }),
2831
3540
  },
2832
- h(InsertIcon, { size: 14 }),
2833
- ),
3541
+ extra: {
3542
+ title: t('insertNewSession'),
3543
+ onClick: () => insertNewSession([segment]),
3544
+ },
3545
+ }),
2834
3546
  h(
2835
3547
  'button',
2836
3548
  {
@@ -2843,32 +3555,35 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2843
3555
  },
2844
3556
  h(ReplaceIcon, { size: 14 }),
2845
3557
  ),
2846
- h(
2847
- 'button',
2848
- {
2849
- type: 'button',
2850
- className: 'dsh-np-segbtn',
3558
+ actionGroup({
3559
+ size: 'card',
3560
+ disabled: empty || sending,
3561
+ primary: {
2851
3562
  title: t('sendSegment'),
2852
- 'aria-label': t('sendSegment'),
2853
- disabled: empty || sending,
2854
3563
  onClick: () => sendSegments([segment]),
3564
+ icon: h(SendIcon, { size: 14 }),
2855
3565
  },
2856
- h(SendIcon, { size: 14 }),
2857
- ),
3566
+ extra: {
3567
+ title: t('sendNewSession'),
3568
+ onClick: () => sendNewSession([segment]),
3569
+ },
3570
+ }),
2858
3571
  h(
2859
3572
  'button',
2860
3573
  {
2861
3574
  type: 'button',
2862
3575
  className: 'dsh-np-segbtn',
2863
3576
  '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),
3577
+ title: aiTitle,
3578
+ 'aria-label': aiTitle,
3579
+ disabled: aiBusy || (canUndoOptimize ? false : empty),
3580
+ onClick: canUndoOptimize
3581
+ ? () => undoOptimizeSegment(segment.id)
3582
+ : () => optimizeSegment(segment.id),
2868
3583
  },
2869
- optimizing.has(segment.id)
3584
+ optimizingNow
2870
3585
  ? h('span', { className: 'dsh-np-mini-spinner' })
2871
- : h(SparkleIcon, { size: 14 }),
3586
+ : canUndoOptimize ? h(UndoIcon, { size: 14 }) : h(SparkleIcon, { size: 14 }),
2872
3587
  ),
2873
3588
  h(
2874
3589
  'button',
@@ -2881,6 +3596,18 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2881
3596
  },
2882
3597
  h(ArchiveIcon, { size: 14 }),
2883
3598
  ),
3599
+ h(
3600
+ 'button',
3601
+ {
3602
+ type: 'button',
3603
+ className: 'dsh-np-segbtn',
3604
+ 'data-danger': 'true',
3605
+ title: t('deleteActiveSegment'),
3606
+ 'aria-label': t('deleteActiveSegment'),
3607
+ onClick: () => setDeleteConfirm(segment.id),
3608
+ },
3609
+ h(TrashIcon, { size: 14 }),
3610
+ ),
2884
3611
  ),
2885
3612
  h(
2886
3613
  'div',
@@ -3077,7 +3804,17 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
3077
3804
  h('span', { className: 'dsh-np-count' },
3078
3805
  `${t('chars', { n: totalChars })} · ${t('segments', { n: activeSegments.length })}`
3079
3806
  + (totalImages > 0 ? ` · ${t('images', { n: totalImages })}` : '')),
3080
- h('span', { className: 'dsh-np-hint', title: t('hint') }, t('hint')),
3807
+ h(
3808
+ 'button',
3809
+ {
3810
+ type: 'button',
3811
+ className: 'dsh-np-hint',
3812
+ title: t('hintNext'),
3813
+ 'aria-label': t('hintNext'),
3814
+ onClick: () => setHintIndex((index) => (index + 1) % HINT_KEYS.length),
3815
+ },
3816
+ t(HINT_KEYS[hintIndex % HINT_KEYS.length]),
3817
+ ),
3081
3818
  );
3082
3819
 
3083
3820
  const toastNode = toast === null
@@ -3117,6 +3854,107 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
3117
3854
  onPointerDown: onResizePointerDown,
3118
3855
  });
3119
3856
 
3857
+ const previewNode = previewImage === null
3858
+ ? null
3859
+ : h(
3860
+ 'div',
3861
+ {
3862
+ className: 'dsh-np-preview',
3863
+ role: 'dialog',
3864
+ 'aria-modal': 'true',
3865
+ 'aria-label': t('imagePreview'),
3866
+ onClick: () => setPreviewImage(null),
3867
+ },
3868
+ h('img', {
3869
+ className: 'dsh-np-preview-img',
3870
+ src: imageUrl(previewImage.path, binding.cwd),
3871
+ alt: previewImage.name,
3872
+ onClick: (event) => event?.stopPropagation?.(),
3873
+ }),
3874
+ h(
3875
+ 'div',
3876
+ { className: 'dsh-np-preview-bar' },
3877
+ h(
3878
+ 'button',
3879
+ {
3880
+ type: 'button',
3881
+ className: 'dsh-np-preview-btn',
3882
+ title: t('openOriginal'),
3883
+ 'aria-label': t('openOriginal'),
3884
+ onClick: (event) => {
3885
+ event?.stopPropagation?.();
3886
+ openOriginalImage(previewImage);
3887
+ },
3888
+ },
3889
+ h(OpenIcon, { size: 15 }),
3890
+ ),
3891
+ h(
3892
+ 'button',
3893
+ {
3894
+ type: 'button',
3895
+ className: 'dsh-np-preview-btn',
3896
+ title: t('closePreview'),
3897
+ 'aria-label': t('closePreview'),
3898
+ onClick: (event) => {
3899
+ event?.stopPropagation?.();
3900
+ setPreviewImage(null);
3901
+ },
3902
+ },
3903
+ h(CloseIcon, { size: 15 }),
3904
+ ),
3905
+ ),
3906
+ h('div', { className: 'dsh-np-preview-name' }, previewImage.name),
3907
+ );
3908
+
3909
+ const deleteTarget = deleteConfirm === ''
3910
+ ? undefined
3911
+ : segments.find((segment) => segment.id === deleteConfirm && segment.archived !== true);
3912
+ const confirmNode = deleteTarget === undefined
3913
+ ? null
3914
+ : h(
3915
+ 'div',
3916
+ {
3917
+ className: 'dsh-np-confirm',
3918
+ role: 'alertdialog',
3919
+ 'aria-modal': 'true',
3920
+ 'aria-label': t('deleteConfirmTitle'),
3921
+ onClick: () => setDeleteConfirm(''),
3922
+ },
3923
+ h(
3924
+ 'div',
3925
+ {
3926
+ className: 'dsh-np-confirm-card',
3927
+ onClick: (event) => event?.stopPropagation?.(),
3928
+ },
3929
+ h('div', { className: 'dsh-np-confirm-title' }, t('deleteConfirmTitle')),
3930
+ h('div', { className: 'dsh-np-confirm-body' }, t('deleteConfirmBody')),
3931
+ h('div', { className: 'dsh-np-confirm-hint' }, t('deleteConfirmHint')),
3932
+ h(
3933
+ 'div',
3934
+ { className: 'dsh-np-confirm-actions' },
3935
+ h(
3936
+ 'button',
3937
+ {
3938
+ type: 'button',
3939
+ className: 'dsh-np-confirm-btn',
3940
+ onClick: () => setDeleteConfirm(''),
3941
+ },
3942
+ t('deleteConfirmCancel'),
3943
+ ),
3944
+ h(
3945
+ 'button',
3946
+ {
3947
+ type: 'button',
3948
+ className: 'dsh-np-confirm-btn',
3949
+ 'data-danger': 'true',
3950
+ onClick: () => deleteActiveSegment(deleteTarget.id),
3951
+ },
3952
+ t('deleteConfirmOk'),
3953
+ ),
3954
+ ),
3955
+ ),
3956
+ );
3957
+
3120
3958
  const card = h(
3121
3959
  'section',
3122
3960
  {
@@ -3139,6 +3977,8 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
3139
3977
  },
3140
3978
  header,
3141
3979
  body,
3980
+ previewNode,
3981
+ confirmNode,
3142
3982
  resizeHandle,
3143
3983
  );
3144
3984
 
@@ -3204,6 +4044,43 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
3204
4044
  };
3205
4045
  }, 'dsh-notepad: dictionaries');
3206
4046
 
4047
+ // Register the geometry-reset chord with DSH's shortcut service so it
4048
+ // reaches the page even when the host intercepts native key bindings.
4049
+ const installResetShortcut = (scope) => {
4050
+ const service = typeof scope.get === 'function' ? scope.get('shortcuts') : undefined;
4051
+ if (service === undefined || service === null || typeof service.register !== 'function') return;
4052
+ try {
4053
+ scope.effect(() => service.register({
4054
+ id: 'notepad.resetGeometry',
4055
+ label: () => translate('resetShortcut'),
4056
+ aliases: ['reset notepad', 'reset notepad geometry'],
4057
+ 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'] },
4064
+ },
4065
+ regions: ['page', 'editable'],
4066
+ modals: [],
4067
+ resolve: () => ({ status: 'handled', run: requestUiReset }),
4068
+ }), 'dsh-notepad: reset shortcut');
4069
+ resetShortcutHandledByHost = true;
4070
+ } catch (error) {
4071
+ console.warn('[dsh-notepad] reset shortcut registration failed:', error?.message ?? error);
4072
+ }
4073
+ };
4074
+ if (ctx.get('shortcuts') !== undefined) {
4075
+ installResetShortcut(ctx);
4076
+ } else if (typeof ctx.inject === 'function') {
4077
+ try {
4078
+ ctx.inject(['shortcuts'], (scoped) => installResetShortcut(scoped));
4079
+ } catch {
4080
+ /* The raw key listener and header button remain as fallbacks. */
4081
+ }
4082
+ }
4083
+
3207
4084
  const slots = ctx.get('slots');
3208
4085
  if (slots === undefined || typeof slots.register !== 'function' || typeof slots.inject !== 'function') {
3209
4086
  console.warn('[dsh-notepad] slots service missing; floating notepad disabled');