@restregular/dsh-notepad 0.3.0 → 0.4.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
@@ -50,8 +50,12 @@ window.__ModuleLoader__.load({
50
50
  const SAVE_DELAY = 500;
51
51
  const MAX_SEGMENTS = 200;
52
52
  const MAX_SEGMENT_CHARS = 160000;
53
+ const MAX_TITLE_CHARS = 200;
53
54
  const MAX_IMAGES = 20;
54
55
  const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
56
+ const MAX_OPTIMIZE_CHARS = 60000;
57
+ const SIDEBAR_REFRESH_MS = 20000;
58
+ const IMAGE_DELETE_DELAY = 5000;
55
59
  const IMAGE_MIME = {
56
60
  'image/png': 'png',
57
61
  'image/jpeg': 'jpg',
@@ -68,6 +72,9 @@ window.__ModuleLoader__.load({
68
72
  editor: '分段内容',
69
73
  placeholder: '记录开发中遇到的问题,或下一阶段要做的事……',
70
74
  segmentPlaceholder: '新分段…',
75
+ titlePlaceholder: '分段标题(可选)',
76
+ segmentTitle: '分段标题',
77
+ indexHint: '点击选择此段 · 拖动可排序',
71
78
  loading: '读取中…',
72
79
  empty: '还没有内容',
73
80
  unsaved: '未保存',
@@ -102,14 +109,30 @@ window.__ModuleLoader__.load({
102
109
  selectHint: '勾选分段可批量操作',
103
110
  selected: '已选 {n} 段',
104
111
  selectSegment: '选择此段',
112
+ merge: '合并分段',
113
+ mergeSelected: '合并选中的 {n} 段',
114
+ mergeHint: '至少选择 2 段才能合并',
115
+ mergedToast: '已合并 {n} 段',
116
+ optimizeSegment: 'AI 优化此段',
117
+ optimizeAll: 'AI 优化全部分段',
118
+ optimizeAllProgress: 'AI 优化中 {done}/{total}',
119
+ optimizing: '优化中…',
120
+ optimizedToast: '已优化此段',
121
+ optimizedCount: '已优化 {n} 段',
122
+ optimizeEmpty: '没有可优化的文字',
123
+ optimizeTooLong: '内容太长,无法优化',
124
+ optimizeUnavailable: 'AI 服务不可用',
125
+ optimizeFailed: '优化失败',
126
+ dragSegment: '拖动排序',
127
+ unsent: '{n} 条未发送',
105
128
  archive: '归档',
106
129
  archiveSegment: '归档此段',
107
130
  archiveSelected: '归档选中的 {n} 段',
108
131
  archived: '归档',
109
132
  archivedToast: '已归档,可在归档中查看',
110
133
  archivedCount: '已归档 {n} 段',
111
- showArchive: '显示归档({n})',
112
- hideArchive: '隐藏归档({n})',
134
+ showArchive: '显示归档',
135
+ hideArchive: '隐藏归档',
113
136
  archiveCount: '归档 · {n}',
114
137
  archiveEmpty: '还没有归档的分段',
115
138
  noActive: '没有进行中的分段',
@@ -153,6 +176,9 @@ window.__ModuleLoader__.load({
153
176
  editor: 'Segment content',
154
177
  placeholder: 'Jot down problems, or what to build next…',
155
178
  segmentPlaceholder: 'New segment…',
179
+ titlePlaceholder: 'Segment title (optional)',
180
+ segmentTitle: 'Segment title',
181
+ indexHint: 'Click to select · drag to reorder',
156
182
  loading: 'Loading…',
157
183
  empty: 'Nothing here yet',
158
184
  unsaved: 'Unsaved',
@@ -187,14 +213,30 @@ window.__ModuleLoader__.load({
187
213
  selectHint: 'Check segments to batch',
188
214
  selected: '{n} selected',
189
215
  selectSegment: 'Select this segment',
216
+ merge: 'Merge segments',
217
+ mergeSelected: 'Merge {n} selected',
218
+ mergeHint: 'Select at least 2 segments to merge',
219
+ mergedToast: 'Merged {n} segments',
220
+ optimizeSegment: 'Improve this segment with AI',
221
+ optimizeAll: 'Improve all segments with AI',
222
+ optimizeAllProgress: 'Improving {done}/{total}',
223
+ optimizing: 'Improving…',
224
+ optimizedToast: 'Segment improved',
225
+ optimizedCount: 'Improved {n} segments',
226
+ optimizeEmpty: 'Nothing to improve',
227
+ optimizeTooLong: 'Too long to improve',
228
+ optimizeUnavailable: 'AI service unavailable',
229
+ optimizeFailed: 'Improve failed',
230
+ dragSegment: 'Drag to reorder',
231
+ unsent: '{n} unsent',
190
232
  archive: 'Archive',
191
233
  archiveSegment: 'Archive this segment',
192
234
  archiveSelected: 'Archive {n} selected',
193
235
  archived: 'Archived',
194
236
  archivedToast: 'Archived; available under Archive',
195
237
  archivedCount: 'Archived {n}',
196
- showArchive: 'Show archive ({n})',
197
- hideArchive: 'Hide archive ({n})',
238
+ showArchive: 'Show archive',
239
+ hideArchive: 'Hide archive',
198
240
  archiveCount: 'Archive · {n}',
199
241
  archiveEmpty: 'Nothing archived yet',
200
242
  noActive: 'No active segments',
@@ -488,6 +530,42 @@ window.__ModuleLoader__.load({
488
530
  );
489
531
  }
490
532
 
533
+ /** Four-point sparkle: an AI improvement action. */
534
+ function SparkleIcon({ size = 16 }) {
535
+ return h(
536
+ Svg,
537
+ { size, viewBox: '0 0 16 16' },
538
+ h('path', {
539
+ d: 'M8 1.8c.5 2.7 1.5 3.7 4.2 4.2-2.7.5-3.7 1.5-4.2 4.2-.5-2.7-1.5-3.7-4.2-4.2C6.5 5.5 7.5 4.5 8 1.8Z',
540
+ stroke: 'currentColor', strokeWidth: 1.2,
541
+ strokeLinecap: 'round', strokeLinejoin: 'round',
542
+ }),
543
+ h('path', {
544
+ d: 'M12.4 9.6c.3 1.5.8 2 2.3 2.3-1.5.3-2 .8-2.3 2.3-.3-1.5-.8-2-2.3-2.3 1.5-.3 2-.8 2.3-2.3Z',
545
+ stroke: 'currentColor', strokeWidth: 1.1,
546
+ strokeLinecap: 'round', strokeLinejoin: 'round',
547
+ }),
548
+ );
549
+ }
550
+
551
+ /** Two arrows converging: merge selected segments into one. */
552
+ function MergeIcon({ size = 16 }) {
553
+ return h(
554
+ Svg,
555
+ { size, viewBox: '0 0 16 16' },
556
+ h('path', {
557
+ d: 'M3.2 3.4v2.2c0 1.4 1.1 2.5 2.5 2.5h4.6c1.4 0 2.5 1.1 2.5 2.5v2.2',
558
+ stroke: 'currentColor', strokeWidth: 1.3,
559
+ strokeLinecap: 'round', strokeLinejoin: 'round',
560
+ }),
561
+ h('path', {
562
+ d: 'M3.2 12.6v-2.2c0-1.4 1.1-2.5 2.5-2.5h4.6c1.4 0 2.5-1.1 2.5-2.5V3.4',
563
+ stroke: 'currentColor', strokeWidth: 1.3,
564
+ strokeLinecap: 'round', strokeLinejoin: 'round',
565
+ }),
566
+ );
567
+ }
568
+
491
569
  /** Arrow towards the bottom-right corner: minimize to the corner icon. */
492
570
  function MinimizeIcon({ size = 16 }) {
493
571
  return h(
@@ -531,10 +609,9 @@ window.__ModuleLoader__.load({
531
609
  .dsh-np-btn[data-accent=true]:hover:not(:disabled){background:color-mix(in srgb,var(--np-accent) 88%,#000);color:var(--np-accent-fg)}
532
610
  .dsh-np-btn:disabled{opacity:.38;cursor:default;background:transparent;color:var(--dsw-alias-label-tertiary,#6b7280)}
533
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}
534
- .dsh-np-badge{position:absolute;top:2px;right:2px;min-width:13px;height:13px;padding:0 3px;box-sizing:border-box;border-radius:7px;background:var(--np-accent);color:var(--np-accent-fg);font-size:9px;font-weight:600;line-height:13px;text-align:center;font-variant-numeric:tabular-nums}
535
612
  .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}
536
613
  .dsh-np-card[data-minimized=true] .dsh-np-body{opacity:0;visibility:hidden;pointer-events:none;transition:opacity .16s ease,visibility 0s linear .16s}
537
- .dsh-np-tools{display:flex;align-items:center;gap:4px;flex:none;padding:0 2px 7px;border-bottom:1px solid var(--dsw-alias-border-l1,rgb(15 23 42/.08))}
614
+ .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))}
538
615
  .dsh-np-selectall{display:inline-flex;align-items:center;gap:6px;min-width:0;cursor:pointer;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:11px;white-space:nowrap}
539
616
  .dsh-np-selectall input{width:14px;height:14px;margin:0;accent-color:var(--np-accent);cursor:pointer;flex:none}
540
617
  .dsh-np-selectall input:disabled{cursor:default;opacity:.45}
@@ -544,12 +621,23 @@ window.__ModuleLoader__.load({
544
621
  .dsh-np-seg[data-selected=true]{border-color:var(--np-accent);background:color-mix(in srgb,var(--np-accent) 6%,var(--np-surface-2));box-shadow:0 0 0 1px var(--np-accent) inset}
545
622
  .dsh-np-seg[data-archived=true]{border-style:dashed;background:transparent;opacity:.82;animation:none}
546
623
  .dsh-np-seg-head{display:flex;align-items:center;gap:3px;flex:none;min-height:30px;padding:3px 5px 0 7px;box-sizing:border-box}
624
+ .dsh-np-seg[data-reorder-drag=true]{opacity:.45}
625
+ .dsh-np-seg[data-drop-before=true]{box-shadow:0 -2px 0 0 var(--np-accent)}
626
+ .dsh-np-seg[data-drop-after=true]{box-shadow:0 2px 0 0 var(--np-accent)}
547
627
  .dsh-np-check{display:inline-flex;align-items:center;justify-content:center;flex:none;width:20px;height:20px;cursor:pointer}
548
628
  .dsh-np-check input{width:14px;height:14px;margin:0;accent-color:var(--np-accent);cursor:pointer}
549
- .dsh-np-index{flex:none;color:var(--dsw-alias-label-caption,#9aa3b2);font-size:10px;font-weight:600;letter-spacing:.04em;font-variant-numeric:tabular-nums}
629
+ .dsh-np-index{flex:none;display:inline-flex;align-items:center;justify-content:center;min-width:22px;height:20px;padding:0 2px;border:0;border-radius:5px;background:transparent;color:var(--dsw-alias-label-caption,#9aa3b2);font:inherit;font-size:10px;font-weight:700;letter-spacing:.04em;font-variant-numeric:tabular-nums;cursor:pointer;user-select:none}
630
+ .dsh-np-index:hover{background:var(--dsw-alias-interactive-bg-hover,rgb(38 49 72/.06));color:var(--dsw-alias-label-primary,#1f2430)}
631
+ .dsh-np-index[aria-pressed=true]{background:color-mix(in srgb,var(--np-accent) 14%,transparent);color:var(--np-accent)}
632
+ .dsh-np-title-input{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-secondary,#4b5563);font:inherit;font-size:11px;font-weight:600;line-height:20px;outline:0}
633
+ .dsh-np-title-input::placeholder{color:var(--dsw-alias-label-caption,#9aa3b2);font-weight:400}
634
+ .dsh-np-title-input:hover{background:var(--dsw-alias-interactive-bg-hover,rgb(38 49 72/.06))}
635
+ .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)}
636
+ .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}
550
637
  .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}
551
638
  .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}
552
639
  .dsh-np-segbtn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgb(38 49 72/.06));color:var(--dsw-alias-label-primary,#1f2430);opacity:1}
640
+ .dsh-np-segbtn[data-ai=true]{color:var(--np-accent);opacity:1}
553
641
  .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))}
554
642
  .dsh-np-segbtn:disabled{opacity:.3;cursor:default}
555
643
  .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}
@@ -567,6 +655,7 @@ window.__ModuleLoader__.load({
567
655
  .dsh-np-img-remove{display:inline-flex;align-items:center;justify-content:center;flex:none;width:17px;height:17px;padding:0;border:0;border-radius:4px;background:transparent;color:var(--dsw-alias-label-caption,#9aa3b2);cursor:pointer}
568
656
  .dsh-np-img-remove:hover{background:var(--dsw-alias-interactive-bg-hover,rgb(38 49 72/.06));color:var(--dsw-alias-state-error-primary,#e25555)}
569
657
  .dsh-np-img-spinner{flex:none;width:14px;height:14px;border:2px solid color-mix(in srgb,var(--np-accent) 30%,transparent);border-top-color:var(--np-accent);border-radius:50%;animation:dsh-np-spin .8s linear infinite}
658
+ .dsh-np-mini-spinner{flex:none;width:12px;height:12px;border:1.8px solid color-mix(in srgb,var(--np-accent) 30%,transparent);border-top-color:var(--np-accent);border-radius:50%;animation:dsh-np-spin .8s linear infinite}
570
659
  .dsh-np-seg-archived-text{padding:2px 10px 9px;color:var(--dsw-alias-label-secondary,#4b5563);font-size:13px;line-height:1.6;white-space:pre-wrap;overflow-wrap:anywhere}
571
660
  .dsh-np-seg[data-archived=true] .dsh-np-medias{padding:0 10px 8px}
572
661
  .dsh-np-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;flex:none;padding:22px 12px;text-align:center;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:12px}
@@ -598,6 +687,9 @@ window.__ModuleLoader__.load({
598
687
  .dsh-np-restore{position:fixed;right:16px;bottom:16px;z-index:30;display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;padding:0;border:1px solid var(--dsw-alias-border-l2,rgb(15 23 42/.12));border-radius:10px;background:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-secondary,#4b5563);box-shadow:var(--dsw-elevation-lv2,0 8px 24px rgb(15 23 42/.16));cursor:pointer;opacity:0;visibility:hidden;pointer-events:none;transform:translateY(6px) scale(.9);transition:opacity .18s ease,transform .18s ease,visibility 0s linear .18s}
599
688
  .dsh-np-restore[data-visible=true]{opacity:.9;visibility:visible;pointer-events:auto;transform:none;transition:opacity .18s ease,transform .18s ease,visibility 0s linear 0s}
600
689
  .dsh-np-restore[data-visible=true]:hover{opacity:1;transform:translateY(-1px)}
690
+ .dsh-np-restore-badge{position:absolute;top:-5px;right:-5px;min-width:16px;height:16px;padding:0 4px;box-sizing:border-box;border-radius:9px;background:var(--dsw-alias-state-business-primary,#4176e6);color:#fff;font-size:9.5px;font-weight:700;line-height:16px;text-align:center;font-variant-numeric:tabular-nums;box-shadow:0 0 0 1.5px var(--dsw-specific-sidebar-fill,var(--dsw-alias-bg-layer-1,#fff))}
691
+ [data-row-key^="workspace:"][data-np-unsent]{position:relative}
692
+ [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))}
601
693
  @keyframes dsh-np-pulse{0%,100%{opacity:1}50%{opacity:.35}}
602
694
  @keyframes dsh-np-rise{from{opacity:0;transform:translate(-50%,4px)}to{opacity:1;transform:translate(-50%,0)}}
603
695
  @keyframes dsh-np-item-in{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}}
@@ -606,7 +698,7 @@ body[data-ds-dark-theme] .dsh-np-card{--np-accent-fg:#0f1115;box-shadow:0 18px 4
606
698
  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))}
607
699
  body[data-ds-dark-theme] .dsh-np-btn[data-accent=true]:hover:not(:disabled){background:color-mix(in srgb,var(--np-accent) 84%,#fff)}
608
700
  body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
609
- @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{animation:none}}`;
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}}`;
610
702
 
611
703
  // ------------------------------------------------------------------
612
704
  // Helpers
@@ -626,14 +718,38 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
626
718
  return `seg-${Date.now().toString(36)}-${segmentSequence.toString(36)}`;
627
719
  }
628
720
 
629
- function newSegment(text) {
630
- return { id: newSegmentId(), text, images: [], archived: false, archivedAt: null };
721
+ function nowIso() {
722
+ try {
723
+ return new Date().toISOString();
724
+ } catch {
725
+ return '';
726
+ }
727
+ }
728
+
729
+ function newSegment(text, options = {}) {
730
+ return {
731
+ id: newSegmentId(),
732
+ text,
733
+ title: typeof options.title === 'string' ? options.title.slice(0, MAX_TITLE_CHARS) : '',
734
+ createdAt: typeof options.createdAt === 'string' && options.createdAt !== ''
735
+ ? options.createdAt
736
+ : nowIso(),
737
+ images: [],
738
+ archived: false,
739
+ archivedAt: null,
740
+ };
631
741
  }
632
742
 
633
743
  function serializeSegments(segments) {
634
744
  return segments.map((segment) => ({
635
745
  id: segment.id,
636
746
  text: segment.text,
747
+ ...(typeof segment.title === 'string' && segment.title !== ''
748
+ ? { title: segment.title }
749
+ : {}),
750
+ ...(typeof segment.createdAt === 'string' && segment.createdAt !== ''
751
+ ? { createdAt: segment.createdAt }
752
+ : {}),
637
753
  ...(Array.isArray(segment.images) && segment.images.length > 0
638
754
  ? { images: segment.images.map((image) => ({ path: image.path, name: image.name })) }
639
755
  : {}),
@@ -655,6 +771,8 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
655
771
  out.push({
656
772
  id,
657
773
  text: item.text,
774
+ title: typeof item.title === 'string' ? item.title.slice(0, MAX_TITLE_CHARS) : '',
775
+ createdAt: typeof item.createdAt === 'string' && item.createdAt !== '' ? item.createdAt : null,
658
776
  images: normalizeImages(item.images),
659
777
  archived,
660
778
  archivedAt: archived && typeof item.archivedAt === 'string' ? item.archivedAt : null,
@@ -738,6 +856,46 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
738
856
  .join('\n\n');
739
857
  }
740
858
 
859
+ /** Whether a segment still counts as unsent work (active + has content). */
860
+ function isUnsent(segment) {
861
+ if (segment === null || segment === undefined || segment.archived === true) return false;
862
+ if (typeof segment.text === 'string' && segment.text.trim() !== '') return true;
863
+ return Array.isArray(segment.images) && segment.images.length > 0;
864
+ }
865
+
866
+ function countUnsent(segments) {
867
+ let count = 0;
868
+ for (const segment of segments) {
869
+ if (isUnsent(segment)) count += 1;
870
+ }
871
+ return count;
872
+ }
873
+
874
+ /** Whether a whole server payload predates per-segment creation times. */
875
+ function isLegacyPayload(data) {
876
+ const segments = Array.isArray(data?.segments) ? data.segments : [];
877
+ return segments.length > 0 && segments.every((segment) =>
878
+ segment !== null && typeof segment === 'object'
879
+ && (typeof segment.createdAt !== 'string' || segment.createdAt === ''));
880
+ }
881
+
882
+ /**
883
+ * Adopt server content without moving cards the user is looking at.
884
+ * Known ids keep the local order; cards created elsewhere land on top.
885
+ */
886
+ function mergeServerOrder(current, incoming) {
887
+ const byId = new Map();
888
+ for (const segment of incoming) byId.set(segment.id, segment);
889
+ const kept = [];
890
+ for (const segment of current) {
891
+ const next = byId.get(segment.id);
892
+ if (next !== undefined) kept.push(next);
893
+ }
894
+ const known = new Set(kept.map((segment) => segment.id));
895
+ const added = incoming.filter((segment) => !known.has(segment.id));
896
+ return [...added, ...kept];
897
+ }
898
+
741
899
  function isAbsolutePath(value) {
742
900
  const text = String(value ?? '');
743
901
  return text.startsWith('/') || /^[A-Za-z]:\//u.test(text) || text.startsWith('//');
@@ -951,6 +1109,32 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
951
1109
  return readResponse(response);
952
1110
  }
953
1111
 
1112
+ async function optimizeNoteText(root, sessionId, text, title) {
1113
+ const response = await fetch(`${API}/optimize`, {
1114
+ method: 'POST',
1115
+ headers: { 'Content-Type': 'application/json' },
1116
+ body: JSON.stringify({ root, sessionId, text, title }),
1117
+ });
1118
+ return readResponse(response);
1119
+ }
1120
+
1121
+ async function removeStoredImage(root, sessionId, pathValue) {
1122
+ const response = await fetch(`${API}/image`, {
1123
+ method: 'DELETE',
1124
+ headers: { 'Content-Type': 'application/json' },
1125
+ body: JSON.stringify({ root, sessionId, path: pathValue }),
1126
+ });
1127
+ return readResponse(response);
1128
+ }
1129
+
1130
+ async function fetchUnsentCounts(sessionId) {
1131
+ const response = await fetch(`${API}/unsent?sessionId=${encodeURIComponent(sessionId)}`, {
1132
+ method: 'GET',
1133
+ cache: 'no-store',
1134
+ });
1135
+ return readResponse(response);
1136
+ }
1137
+
954
1138
  async function copyText(value) {
955
1139
  try {
956
1140
  if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
@@ -1022,6 +1206,12 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1022
1206
  const [resizing, setResizing] = useState(false);
1023
1207
  const [uploading, setUploading] = useState({});
1024
1208
  const [dragSegment, setDragSegment] = useState('');
1209
+ const [reorderDrag, setReorderDrag] = useState('');
1210
+ const [reorderOver, setReorderOver] = useState(null);
1211
+ const [optimizing, setOptimizing] = useState(() => new Set());
1212
+ const [optimizeAll, setOptimizeAll] = useState(null);
1213
+ const [wsCounts, setWsCounts] = useState({});
1214
+ const [currentWsId, setCurrentWsId] = useState('');
1025
1215
 
1026
1216
  const bindingRef = useRef(null);
1027
1217
  const rootSessionRef = useRef('');
@@ -1046,6 +1236,11 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1046
1236
  const listRef = useRef(null);
1047
1237
  const pendingFocusRef = useRef(null);
1048
1238
  const undoRef = useRef(null);
1239
+ const optimizingRef = useRef(new Set());
1240
+ const optimizeAllRef = useRef(false);
1241
+ const reorderDragRef = useRef('');
1242
+ const reorderOverRef = useRef(null);
1243
+ const imageDeleteTimersRef = useRef(new Map());
1049
1244
 
1050
1245
  const setSelection = (next) => {
1051
1246
  selectedRef.current = next;
@@ -1228,6 +1423,9 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1228
1423
  .then((data) => {
1229
1424
  if (cancelled || rootRef.current !== root || mySeq !== loadSeqRef.current) return;
1230
1425
  let value = normalizeServerSegments(data);
1426
+ // Notes written before 0.4 appended new cards at the bottom;
1427
+ // reverse that legacy order once so the newest card leads.
1428
+ if (isLegacyPayload(data)) value = value.slice().reverse();
1231
1429
  if (value.length === 0) value = [newSegment('')];
1232
1430
  segmentsRef.current = value;
1233
1431
  setSegments(value);
@@ -1252,6 +1450,91 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1252
1450
  };
1253
1451
  }, [binding === null ? '' : binding.id, binding === null ? '' : binding.cwd]);
1254
1452
 
1453
+ // Sidebar workspace badges --------------------------------------------
1454
+ useEffect(() => {
1455
+ if (binding === null) {
1456
+ setCurrentWsId('');
1457
+ setWsCounts({});
1458
+ return undefined;
1459
+ }
1460
+ setCurrentWsId('');
1461
+ let disposed = false;
1462
+ const refresh = () => {
1463
+ const sessionId = binding.id;
1464
+ if (typeof sessionId !== 'string' || sessionId === '') return;
1465
+ fetchUnsentCounts(sessionId)
1466
+ .then((data) => {
1467
+ if (disposed || bindingRef.current?.id !== sessionId) return;
1468
+ const next = {};
1469
+ let matched = '';
1470
+ const target = normalizedCwd(binding.cwd);
1471
+ for (const workspace of Array.isArray(data?.workspaces) ? data.workspaces : []) {
1472
+ const id = typeof workspace?.id === 'string' ? workspace.id : '';
1473
+ if (id === '') continue;
1474
+ next[id] = Number.isFinite(workspace.count) ? Math.max(0, Math.floor(workspace.count)) : 0;
1475
+ if (matched === '' && normalizedCwd(workspace.path) === target) matched = id;
1476
+ }
1477
+ setWsCounts(next);
1478
+ if (matched !== '') setCurrentWsId(matched);
1479
+ })
1480
+ .catch(() => {});
1481
+ };
1482
+ refresh();
1483
+ const timer = setInterval(refresh, SIDEBAR_REFRESH_MS);
1484
+ const onFocus = () => refresh();
1485
+ const onVisibility = () => {
1486
+ if (typeof document !== 'undefined' && document.visibilityState === 'visible') refresh();
1487
+ };
1488
+ window.addEventListener('focus', onFocus);
1489
+ if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility);
1490
+ return () => {
1491
+ disposed = true;
1492
+ clearInterval(timer);
1493
+ window.removeEventListener('focus', onFocus);
1494
+ if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility);
1495
+ };
1496
+ }, [binding === null ? '' : binding.id, binding === null ? '' : binding.cwd]);
1497
+
1498
+ const localUnsentCount = countUnsent(segments);
1499
+
1500
+ useEffect(() => {
1501
+ if (currentWsId === '') return;
1502
+ setWsCounts((previous) =>
1503
+ (previous[currentWsId] === localUnsentCount
1504
+ ? previous
1505
+ : { ...previous, [currentWsId]: localUnsentCount }));
1506
+ }, [currentWsId, localUnsentCount]);
1507
+
1508
+ useEffect(() => {
1509
+ if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return undefined;
1510
+ const apply = () => {
1511
+ let rows;
1512
+ try {
1513
+ rows = document.querySelectorAll('[data-row-key^="workspace:"]');
1514
+ } catch {
1515
+ return;
1516
+ }
1517
+ for (const row of rows) {
1518
+ const key = typeof row.getAttribute === 'function' ? String(row.getAttribute('data-row-key') ?? '') : '';
1519
+ const id = key.startsWith('workspace:') ? key.slice('workspace:'.length) : '';
1520
+ const count = id === '' ? 0 : (wsCounts[id] ?? 0);
1521
+ if (count > 0) row.setAttribute('data-np-unsent', count > 99 ? '99+' : String(count));
1522
+ else row.removeAttribute('data-np-unsent');
1523
+ }
1524
+ };
1525
+ apply();
1526
+ const Observer = typeof globalThis !== 'undefined' ? globalThis.MutationObserver : undefined;
1527
+ if (typeof Observer !== 'function' || typeof document.body === 'undefined' || document.body === null) {
1528
+ return () => apply();
1529
+ }
1530
+ const observer = new Observer(apply);
1531
+ observer.observe(document.body, { childList: true, subtree: true });
1532
+ return () => {
1533
+ observer.disconnect();
1534
+ apply();
1535
+ };
1536
+ }, [wsCounts]);
1537
+
1255
1538
  // Focus a just-created or restored card ------------------------------
1256
1539
  useEffect(() => {
1257
1540
  const pending = pendingFocusRef.current;
@@ -1297,6 +1580,8 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1297
1580
  window.removeEventListener('keydown', onKeyDown, true);
1298
1581
  clearTimer();
1299
1582
  if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
1583
+ for (const timer of imageDeleteTimersRef.current.values()) clearTimeout(timer);
1584
+ imageDeleteTimersRef.current.clear();
1300
1585
  };
1301
1586
  }, []);
1302
1587
 
@@ -1335,11 +1620,13 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1335
1620
  .then((data) => {
1336
1621
  if (rootRef.current !== root || dirtyRef.current) return;
1337
1622
  if (typeof data.mtimeMs === 'number' && data.mtimeMs === versionRef.current) return;
1338
- let value = normalizeServerSegments(data);
1339
- if (value.length === 0) value = [newSegment('')];
1623
+ let incoming = normalizeServerSegments(data);
1624
+ if (isLegacyPayload(data)) incoming = incoming.slice().reverse();
1625
+ if (incoming.length === 0) incoming = [newSegment('')];
1626
+ const value = mergeServerOrder(segmentsRef.current, incoming);
1340
1627
  segmentsRef.current = value;
1341
1628
  setSegments(value);
1342
- setSelection(new Set());
1629
+ pruneSelectionFor(value);
1343
1630
  versionRef.current = typeof data.mtimeMs === 'number' ? data.mtimeMs : null;
1344
1631
  setSavedAt(typeof data.mtimeMs === 'number' ? data.mtimeMs : null);
1345
1632
  setStatus('saved');
@@ -1372,6 +1659,19 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1372
1659
  scheduleSave();
1373
1660
  };
1374
1661
 
1662
+ const updateSegmentTitle = (id, value) => {
1663
+ const title = String(value ?? '').slice(0, MAX_TITLE_CHARS);
1664
+ const target = segmentsRef.current.find((segment) => segment.id === id);
1665
+ if (target === undefined || target.title === title) return;
1666
+ const next = segmentsRef.current.map((segment) => (segment.id === id ? { ...segment, title } : segment));
1667
+ segmentsRef.current = next;
1668
+ setSegments(next);
1669
+ dirtyRef.current = true;
1670
+ setStatus('dirty');
1671
+ setError('');
1672
+ scheduleSave();
1673
+ };
1674
+
1375
1675
  // Image attachments ---------------------------------------------------
1376
1676
  const imageErrorText = (reason) => {
1377
1677
  const code = reason?.code;
@@ -1446,13 +1746,46 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1446
1746
  }
1447
1747
  };
1448
1748
 
1749
+ const clearPendingImageDeletes = () => {
1750
+ for (const timer of imageDeleteTimersRef.current.values()) {
1751
+ clearTimeout(timer);
1752
+ }
1753
+ imageDeleteTimersRef.current.clear();
1754
+ };
1755
+
1756
+ const deleteImageFile = (pathValue) => {
1757
+ const root = rootRef.current;
1758
+ const sessionId = rootSessionRef.current;
1759
+ if (root === '' || sessionId === '' || typeof pathValue !== 'string' || pathValue === '') return;
1760
+ removeStoredImage(root, sessionId, pathValue).catch(() => {});
1761
+ };
1762
+
1763
+ const scheduleImageCleanup = (images) => {
1764
+ const list = Array.isArray(images) ? images : [];
1765
+ for (const image of list) {
1766
+ const pathValue = typeof image?.path === 'string' ? image.path : '';
1767
+ if (pathValue === '' || imageDeleteTimersRef.current.has(pathValue)) continue;
1768
+ const timer = setTimeout(() => {
1769
+ imageDeleteTimersRef.current.delete(pathValue);
1770
+ if (!segmentsRef.current.some((segment) =>
1771
+ (Array.isArray(segment.images) ? segment.images : []).some((item) => item.path === pathValue))) {
1772
+ deleteImageFile(pathValue);
1773
+ }
1774
+ }, IMAGE_DELETE_DELAY);
1775
+ imageDeleteTimersRef.current.set(pathValue, timer);
1776
+ }
1777
+ };
1778
+
1449
1779
  const removeImage = (id, pathValue) => {
1450
1780
  const next = segmentsRef.current.map((segment) => {
1451
1781
  if (segment.id !== id) return segment;
1452
1782
  const images = Array.isArray(segment.images) ? segment.images.filter((image) => image.path !== pathValue) : [];
1453
1783
  return { ...segment, images };
1454
1784
  });
1785
+ const stillReferenced = next.some((segment) =>
1786
+ (Array.isArray(segment.images) ? segment.images : []).some((image) => image.path === pathValue));
1455
1787
  commitSegments(next);
1788
+ if (!stillReferenced) deleteImageFile(pathValue);
1456
1789
  };
1457
1790
 
1458
1791
  const triggerFilePicker = (id) => {
@@ -1525,7 +1858,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1525
1858
  return;
1526
1859
  }
1527
1860
  const segment = newSegment('');
1528
- commitSegments([...segmentsRef.current, segment], { focus: { id: segment.id, caret: 0 } });
1861
+ commitSegments([segment, ...segmentsRef.current], { focus: { id: segment.id, caret: 0 } });
1529
1862
  };
1530
1863
 
1531
1864
  const splitSegment = (id, element) => {
@@ -1535,15 +1868,21 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1535
1868
  const head = current.text.slice(0, start);
1536
1869
  const tail = current.text.slice(start);
1537
1870
  const nextSegment = newSegment(tail);
1538
- const next = [];
1539
- for (const segment of segmentsRef.current) {
1540
- if (segment.id === id) {
1541
- next.push({ ...segment, text: head });
1542
- next.push(nextSegment);
1543
- } else {
1544
- next.push(segment);
1545
- }
1546
- }
1871
+ const updated = segmentsRef.current.map((segment) =>
1872
+ (segment.id === id ? { ...segment, text: head } : segment));
1873
+ // Enter at the end creates a fresh card, which belongs at the top.
1874
+ // Splitting in the middle keeps the tail next to its source card so
1875
+ // the caret does not jump away from what the user is editing.
1876
+ const next = tail.trim() === ''
1877
+ ? [nextSegment, ...updated]
1878
+ : (() => {
1879
+ const out = [];
1880
+ for (const segment of updated) {
1881
+ out.push(segment);
1882
+ if (segment.id === id) out.push(nextSegment);
1883
+ }
1884
+ return out;
1885
+ })();
1547
1886
  commitSegments(next, { focus: { id: nextSegment.id, caret: 0 } });
1548
1887
  };
1549
1888
 
@@ -1595,6 +1934,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1595
1934
  const record = undoRef.current;
1596
1935
  if (record === null || record === undefined) return;
1597
1936
  undoRef.current = null;
1937
+ clearPendingImageDeletes();
1598
1938
  commitSegments(record.snapshot);
1599
1939
  setToast(null);
1600
1940
  };
@@ -1605,6 +1945,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1605
1945
  undoRef.current = { snapshot: segmentsRef.current };
1606
1946
  const next = segmentsRef.current.filter((segment) => segment.id !== id);
1607
1947
  commitSegments(next);
1948
+ scheduleImageCleanup(target.images);
1608
1949
  showToast(t('deletedToast'), { label: t('undo'), onClick: undoLast });
1609
1950
  };
1610
1951
 
@@ -1614,9 +1955,226 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1614
1955
  undoRef.current = { snapshot: segmentsRef.current };
1615
1956
  const next = segmentsRef.current.filter((segment) => segment.archived !== true);
1616
1957
  commitSegments(next);
1958
+ scheduleImageCleanup(archived.flatMap((segment) => (Array.isArray(segment.images) ? segment.images : [])));
1617
1959
  showToast(t('clearedArchive', { n: archived.length }), { label: t('undo'), onClick: undoLast });
1618
1960
  };
1619
1961
 
1962
+ // Merge / reorder -----------------------------------------------------
1963
+ const mergeSelected = () => {
1964
+ const ids = new Set(
1965
+ segmentsRef.current
1966
+ .filter((segment) => segment.archived !== true && selectedRef.current.has(segment.id))
1967
+ .map((segment) => segment.id),
1968
+ );
1969
+ if (ids.size < 2) {
1970
+ showToast(t('mergeHint'));
1971
+ return;
1972
+ }
1973
+ const chosen = segmentsRef.current.filter((segment) => ids.has(segment.id));
1974
+ const firstIndex = segmentsRef.current.findIndex((segment) => ids.has(segment.id));
1975
+ let insertAt = 0;
1976
+ for (let index = 0; index < firstIndex; index += 1) {
1977
+ if (!ids.has(segmentsRef.current[index].id)) insertAt += 1;
1978
+ }
1979
+ const seenPaths = new Set();
1980
+ const images = [];
1981
+ for (const segment of chosen) {
1982
+ for (const image of Array.isArray(segment.images) ? segment.images : []) {
1983
+ if (seenPaths.has(image.path) || images.length >= MAX_IMAGES) continue;
1984
+ seenPaths.add(image.path);
1985
+ images.push(image);
1986
+ }
1987
+ }
1988
+ const merged = {
1989
+ id: newSegmentId(),
1990
+ text: chosen.map((segment) => segment.text).filter((text) => text.trim() !== '').join('\n\n'),
1991
+ title: chosen
1992
+ .map((segment) => (typeof segment.title === 'string' ? segment.title.trim() : ''))
1993
+ .filter((title) => title !== '')
1994
+ .join(' · ')
1995
+ .slice(0, MAX_TITLE_CHARS),
1996
+ createdAt: nowIso(),
1997
+ images,
1998
+ archived: false,
1999
+ archivedAt: null,
2000
+ };
2001
+ undoRef.current = { snapshot: segmentsRef.current };
2002
+ const next = segmentsRef.current.filter((segment) => !ids.has(segment.id));
2003
+ next.splice(Math.min(insertAt, next.length), 0, merged);
2004
+ setSelection(new Set());
2005
+ commitSegments(next, { focus: { id: merged.id, caret: merged.text.length } });
2006
+ showToast(t('mergedToast', { n: chosen.length }), { label: t('undo'), onClick: undoLast });
2007
+ };
2008
+
2009
+ const reorderSegments = (sourceId, targetId, after) => {
2010
+ if (sourceId === '' || sourceId === targetId) return;
2011
+ const active = segmentsRef.current.filter((segment) => segment.archived !== true);
2012
+ const from = active.findIndex((segment) => segment.id === sourceId);
2013
+ const to = active.findIndex((segment) => segment.id === targetId);
2014
+ if (from < 0 || to < 0) return;
2015
+ const moved = active.splice(from, 1)[0];
2016
+ let insertAt = to + (after ? 1 : 0);
2017
+ if (from < insertAt) insertAt -= 1;
2018
+ insertAt = Math.max(0, Math.min(insertAt, active.length));
2019
+ active.splice(insertAt, 0, moved);
2020
+ let cursor = 0;
2021
+ const next = segmentsRef.current.map((segment) =>
2022
+ (segment.archived === true ? segment : active[cursor++]));
2023
+ if (next.every((segment, index) => segment.id === segmentsRef.current[index].id)) return;
2024
+ commitSegments(next);
2025
+ };
2026
+
2027
+ const setReorderTarget = (target) => {
2028
+ reorderOverRef.current = target;
2029
+ setReorderOver(target);
2030
+ };
2031
+
2032
+ const onSegmentReorderStart = (event, id) => {
2033
+ reorderDragRef.current = id;
2034
+ setReorderDrag(id);
2035
+ try {
2036
+ event.dataTransfer?.setData('application/x-dsh-notepad-segment', id);
2037
+ if (event.dataTransfer !== undefined && event.dataTransfer !== null) event.dataTransfer.effectAllowed = 'move';
2038
+ } catch {
2039
+ /* Some browsers expose a read-only transfer. */
2040
+ }
2041
+ };
2042
+
2043
+ const onSegmentReorderOver = (event, id) => {
2044
+ const source = reorderDragRef.current;
2045
+ if (source === '' || source === id) return;
2046
+ const types = event?.dataTransfer?.types;
2047
+ if (types !== undefined && types !== null && !Array.from(types).includes('application/x-dsh-notepad-segment')) return;
2048
+ event.preventDefault?.();
2049
+ event.stopPropagation?.();
2050
+ const rect = typeof event.currentTarget?.getBoundingClientRect === 'function'
2051
+ ? event.currentTarget.getBoundingClientRect()
2052
+ : null;
2053
+ const after = rect !== null && Number(event.clientY) > rect.top + rect.height / 2;
2054
+ if (reorderOverRef.current?.id !== id || reorderOverRef.current?.after !== after) {
2055
+ setReorderTarget({ id, after });
2056
+ }
2057
+ };
2058
+
2059
+ const onSegmentReorderDrop = (event, id) => {
2060
+ const source = reorderDragRef.current
2061
+ || (() => {
2062
+ try {
2063
+ return event?.dataTransfer?.getData('application/x-dsh-notepad-segment') ?? '';
2064
+ } catch {
2065
+ return '';
2066
+ }
2067
+ })();
2068
+ const target = reorderOverRef.current?.id === id ? reorderOverRef.current : null;
2069
+ reorderDragRef.current = '';
2070
+ setReorderDrag('');
2071
+ setReorderTarget(null);
2072
+ if (source === '') return;
2073
+ event.preventDefault?.();
2074
+ event.stopPropagation?.();
2075
+ reorderSegments(source, id, target?.after === true);
2076
+ };
2077
+
2078
+ // AI optimization -----------------------------------------------------
2079
+ const markOptimizing = (id, active) => {
2080
+ const next = new Set(optimizingRef.current);
2081
+ if (active) next.add(id);
2082
+ else next.delete(id);
2083
+ optimizingRef.current = next;
2084
+ setOptimizing(next);
2085
+ };
2086
+
2087
+ const requestOptimization = async (segment) => {
2088
+ const root = rootRef.current;
2089
+ const sessionId = rootSessionRef.current;
2090
+ if (root === '' || sessionId === '') throw new Error(t('optimizeUnavailable'));
2091
+ if (segment.text.length > MAX_OPTIMIZE_CHARS) throw new Error(t('optimizeTooLong'));
2092
+ const data = await optimizeNoteText(root, sessionId, segment.text, segment.title ?? '');
2093
+ const value = typeof data.text === 'string' ? data.text.trim() : '';
2094
+ if (value === '') throw new Error(t('optimizeFailed'));
2095
+ return value;
2096
+ };
2097
+
2098
+ const optimizeSegment = async (id) => {
2099
+ if (optimizingRef.current.has(id)) return;
2100
+ const target = segmentsRef.current.find((segment) => segment.id === id && segment.archived !== true);
2101
+ if (target === undefined || target.text.trim() === '') {
2102
+ showToast(t('optimizeEmpty'));
2103
+ return;
2104
+ }
2105
+ const snapshot = segmentsRef.current;
2106
+ const original = target.text;
2107
+ markOptimizing(id, true);
2108
+ try {
2109
+ const value = await requestOptimization(target);
2110
+ const current = segmentsRef.current.find((segment) => segment.id === id);
2111
+ if (current === undefined || current.text !== original || value === original) return;
2112
+ undoRef.current = { snapshot };
2113
+ const next = segmentsRef.current.map((segment) =>
2114
+ (segment.id === id ? { ...segment, text: value } : segment));
2115
+ commitSegments(next);
2116
+ showToast(t('optimizedToast'), { label: t('undo'), onClick: undoLast });
2117
+ } catch (reason) {
2118
+ const message = String(reason?.message ?? reason);
2119
+ showToast(message === t('optimizeTooLong') || message === t('optimizeUnavailable')
2120
+ ? message
2121
+ : `${t('optimizeFailed')}: ${message}`);
2122
+ } finally {
2123
+ markOptimizing(id, false);
2124
+ }
2125
+ };
2126
+
2127
+ const optimizeAllSegments = async () => {
2128
+ if (optimizeAllRef.current) return;
2129
+ const targets = segmentsRef.current.filter((segment) =>
2130
+ segment.archived !== true && segment.text.trim() !== '');
2131
+ if (targets.length === 0) {
2132
+ showToast(t('optimizeEmpty'));
2133
+ return;
2134
+ }
2135
+ optimizeAllRef.current = true;
2136
+ const snapshot = segmentsRef.current;
2137
+ let done = 0;
2138
+ let changed = 0;
2139
+ let failed = 0;
2140
+ setOptimizeAll({ done, total: targets.length });
2141
+ try {
2142
+ for (const target of targets) {
2143
+ const current = segmentsRef.current.find((segment) =>
2144
+ segment.id === target.id && segment.archived !== true);
2145
+ if (current !== undefined && current.text === target.text) {
2146
+ markOptimizing(target.id, true);
2147
+ try {
2148
+ const value = await requestOptimization(current);
2149
+ if (value !== current.text) {
2150
+ const next = segmentsRef.current.map((segment) =>
2151
+ (segment.id === target.id && segment.text === current.text
2152
+ ? { ...segment, text: value }
2153
+ : segment));
2154
+ commitSegments(next);
2155
+ changed += 1;
2156
+ }
2157
+ } catch {
2158
+ failed += 1;
2159
+ } finally {
2160
+ markOptimizing(target.id, false);
2161
+ }
2162
+ }
2163
+ done += 1;
2164
+ setOptimizeAll({ done, total: targets.length });
2165
+ }
2166
+ if (changed > 0) {
2167
+ undoRef.current = { snapshot };
2168
+ showToast(t('optimizedCount', { n: changed }), { label: t('undo'), onClick: undoLast });
2169
+ } else {
2170
+ showToast(failed > 0 ? t('optimizeFailed') : t('optimizedToast'));
2171
+ }
2172
+ } finally {
2173
+ optimizeAllRef.current = false;
2174
+ setOptimizeAll(null);
2175
+ }
2176
+ };
2177
+
1620
2178
  // Selection -----------------------------------------------------------
1621
2179
  const toggleSelected = (id) => {
1622
2180
  const next = new Set(selectedRef.current);
@@ -1903,6 +2461,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1903
2461
  const selectedActive = activeSegments.filter((segment) => selected.has(segment.id));
1904
2462
  const selectedCount = selectedActive.length;
1905
2463
  const allSelected = activeSegments.length > 0 && selectedCount === activeSegments.length;
2464
+ const unsentCount = countUnsent(segments);
1906
2465
  const totalChars = segments.reduce((sum, segment) => sum + segment.text.length, 0);
1907
2466
  const displayWidth = ui.width;
1908
2467
  const displayHeight = ui.minimized ? HEAD_H : ui.height;
@@ -1921,9 +2480,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1921
2480
  statusText = clock === '' ? t('saved') : `${t('saved')} · ${clock}`;
1922
2481
  } else statusText = t('empty');
1923
2482
 
1924
- const archiveButtonTitle = ui.showArchive
1925
- ? t('hideArchive', { n: archivedSegments.length })
1926
- : t('showArchive', { n: archivedSegments.length });
2483
+ const archiveButtonTitle = ui.showArchive ? t('hideArchive') : t('showArchive');
1927
2484
 
1928
2485
  const header = h(
1929
2486
  'div',
@@ -1956,9 +2513,6 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1956
2513
  onClick: toggleArchive,
1957
2514
  },
1958
2515
  h(ArchiveIcon, { size: 15 }),
1959
- archivedSegments.length > 0
1960
- ? h('span', { className: 'dsh-np-badge' }, String(archivedSegments.length))
1961
- : null,
1962
2516
  ),
1963
2517
  h(
1964
2518
  'button',
@@ -2051,6 +2605,35 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2051
2605
  },
2052
2606
  h(SendIcon, { size: 14 }),
2053
2607
  ),
2608
+ h(
2609
+ 'button',
2610
+ {
2611
+ type: 'button',
2612
+ className: 'dsh-np-btn',
2613
+ title: selectedCount > 1 ? t('mergeSelected', { n: selectedCount }) : t('mergeHint'),
2614
+ 'aria-label': t('merge'),
2615
+ disabled: selectedCount < 2,
2616
+ onClick: mergeSelected,
2617
+ },
2618
+ h(MergeIcon, { size: 14 }),
2619
+ ),
2620
+ h(
2621
+ 'button',
2622
+ {
2623
+ type: 'button',
2624
+ 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,
2632
+ },
2633
+ optimizeAll !== null
2634
+ ? h('span', { className: 'dsh-np-mini-spinner' })
2635
+ : h(SparkleIcon, { size: 14 }),
2636
+ ),
2054
2637
  h(
2055
2638
  'button',
2056
2639
  {
@@ -2145,6 +2728,14 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2145
2728
  'data-seg-id': segment.id,
2146
2729
  'data-archived': 'false',
2147
2730
  'data-selected': selected.has(segment.id) ? 'true' : 'false',
2731
+ 'data-reorder-drag': reorderDrag === segment.id ? 'true' : 'false',
2732
+ 'data-drop-before': reorderOver?.id === segment.id && reorderOver?.after === false ? 'true' : 'false',
2733
+ 'data-drop-after': reorderOver?.id === segment.id && reorderOver?.after === true ? 'true' : 'false',
2734
+ onDragOver: (event) => onSegmentReorderOver(event, segment.id),
2735
+ onDragLeave: () => {
2736
+ if (reorderOverRef.current?.id === segment.id) setReorderTarget(null);
2737
+ },
2738
+ onDrop: (event) => onSegmentReorderDrop(event, segment.id),
2148
2739
  },
2149
2740
  h('input', {
2150
2741
  key: 'file-input',
@@ -2176,8 +2767,47 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2176
2767
  onChange: () => toggleSelected(segment.id),
2177
2768
  }),
2178
2769
  ),
2179
- h('span', { className: 'dsh-np-index' }, String(index + 1).padStart(2, '0')),
2180
- h('span', { className: 'dsh-np-spacer' }),
2770
+ h(
2771
+ 'button',
2772
+ {
2773
+ type: 'button',
2774
+ className: 'dsh-np-index',
2775
+ title: t('indexHint'),
2776
+ 'aria-label': t('selectSegment'),
2777
+ 'aria-pressed': selected.has(segment.id) ? 'true' : 'false',
2778
+ draggable: true,
2779
+ onDragStart: (event) => onSegmentReorderStart(event, segment.id),
2780
+ onDragEnd: () => {
2781
+ reorderDragRef.current = '';
2782
+ setReorderDrag('');
2783
+ setReorderTarget(null);
2784
+ },
2785
+ onClick: () => toggleSelected(segment.id),
2786
+ },
2787
+ String(index + 1).padStart(2, '0'),
2788
+ ),
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
+ }),
2181
2811
  h(
2182
2812
  'button',
2183
2813
  {
@@ -2225,6 +2855,21 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2225
2855
  },
2226
2856
  h(SendIcon, { size: 14 }),
2227
2857
  ),
2858
+ h(
2859
+ 'button',
2860
+ {
2861
+ type: 'button',
2862
+ className: 'dsh-np-segbtn',
2863
+ '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),
2868
+ },
2869
+ optimizing.has(segment.id)
2870
+ ? h('span', { className: 'dsh-np-mini-spinner' })
2871
+ : h(SparkleIcon, { size: 14 }),
2872
+ ),
2228
2873
  h(
2229
2874
  'button',
2230
2875
  {
@@ -2342,6 +2987,9 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2342
2987
  'div',
2343
2988
  { className: 'dsh-np-seg-head' },
2344
2989
  h('span', { className: 'dsh-np-archived-chip' }, t('archived')),
2990
+ typeof segment.title === 'string' && segment.title.trim() !== ''
2991
+ ? h('span', { className: 'dsh-np-seg-title' }, segment.title)
2992
+ : null,
2345
2993
  h('span', { className: 'dsh-np-spacer' }),
2346
2994
  h(
2347
2995
  'button',
@@ -2508,6 +3156,12 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2508
3156
  onClick: toggleHidden,
2509
3157
  },
2510
3158
  h(NotepadIcon, { size: 16 }),
3159
+ ui.hidden && unsentCount > 0
3160
+ ? h('span', {
3161
+ className: 'dsh-np-restore-badge',
3162
+ title: t('unsent', { n: unsentCount }),
3163
+ }, unsentCount > 99 ? '99+' : String(unsentCount))
3164
+ : null,
2511
3165
  );
2512
3166
 
2513
3167
  return [card, restore];