@restregular/dsh-notepad 0.3.1 → 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,6 +109,22 @@ 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} 段',
@@ -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,6 +213,22 @@ 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',
@@ -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(
@@ -533,7 +611,7 @@ window.__ModuleLoader__.load({
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
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}
535
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}
536
- .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))}
537
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}
538
616
  .dsh-np-selectall input{width:14px;height:14px;margin:0;accent-color:var(--np-accent);cursor:pointer;flex:none}
539
617
  .dsh-np-selectall input:disabled{cursor:default;opacity:.45}
@@ -543,12 +621,23 @@ window.__ModuleLoader__.load({
543
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}
544
622
  .dsh-np-seg[data-archived=true]{border-style:dashed;background:transparent;opacity:.82;animation:none}
545
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)}
546
627
  .dsh-np-check{display:inline-flex;align-items:center;justify-content:center;flex:none;width:20px;height:20px;cursor:pointer}
547
628
  .dsh-np-check input{width:14px;height:14px;margin:0;accent-color:var(--np-accent);cursor:pointer}
548
- .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}
549
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}
550
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}
551
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}
552
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))}
553
642
  .dsh-np-segbtn:disabled{opacity:.3;cursor:default}
554
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}
@@ -566,6 +655,7 @@ window.__ModuleLoader__.load({
566
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}
567
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)}
568
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}
569
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}
570
660
  .dsh-np-seg[data-archived=true] .dsh-np-medias{padding:0 10px 8px}
571
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}
@@ -597,6 +687,9 @@ window.__ModuleLoader__.load({
597
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}
598
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}
599
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))}
600
693
  @keyframes dsh-np-pulse{0%,100%{opacity:1}50%{opacity:.35}}
601
694
  @keyframes dsh-np-rise{from{opacity:0;transform:translate(-50%,4px)}to{opacity:1;transform:translate(-50%,0)}}
602
695
  @keyframes dsh-np-item-in{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}}
@@ -605,7 +698,7 @@ body[data-ds-dark-theme] .dsh-np-card{--np-accent-fg:#0f1115;box-shadow:0 18px 4
605
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))}
606
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)}
607
700
  body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
608
- @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}}`;
609
702
 
610
703
  // ------------------------------------------------------------------
611
704
  // Helpers
@@ -625,14 +718,38 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
625
718
  return `seg-${Date.now().toString(36)}-${segmentSequence.toString(36)}`;
626
719
  }
627
720
 
628
- function newSegment(text) {
629
- 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
+ };
630
741
  }
631
742
 
632
743
  function serializeSegments(segments) {
633
744
  return segments.map((segment) => ({
634
745
  id: segment.id,
635
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
+ : {}),
636
753
  ...(Array.isArray(segment.images) && segment.images.length > 0
637
754
  ? { images: segment.images.map((image) => ({ path: image.path, name: image.name })) }
638
755
  : {}),
@@ -654,6 +771,8 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
654
771
  out.push({
655
772
  id,
656
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,
657
776
  images: normalizeImages(item.images),
658
777
  archived,
659
778
  archivedAt: archived && typeof item.archivedAt === 'string' ? item.archivedAt : null,
@@ -737,6 +856,46 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
737
856
  .join('\n\n');
738
857
  }
739
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
+
740
899
  function isAbsolutePath(value) {
741
900
  const text = String(value ?? '');
742
901
  return text.startsWith('/') || /^[A-Za-z]:\//u.test(text) || text.startsWith('//');
@@ -950,6 +1109,32 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
950
1109
  return readResponse(response);
951
1110
  }
952
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
+
953
1138
  async function copyText(value) {
954
1139
  try {
955
1140
  if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
@@ -1021,6 +1206,12 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1021
1206
  const [resizing, setResizing] = useState(false);
1022
1207
  const [uploading, setUploading] = useState({});
1023
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('');
1024
1215
 
1025
1216
  const bindingRef = useRef(null);
1026
1217
  const rootSessionRef = useRef('');
@@ -1045,6 +1236,11 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1045
1236
  const listRef = useRef(null);
1046
1237
  const pendingFocusRef = useRef(null);
1047
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());
1048
1244
 
1049
1245
  const setSelection = (next) => {
1050
1246
  selectedRef.current = next;
@@ -1227,6 +1423,9 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1227
1423
  .then((data) => {
1228
1424
  if (cancelled || rootRef.current !== root || mySeq !== loadSeqRef.current) return;
1229
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();
1230
1429
  if (value.length === 0) value = [newSegment('')];
1231
1430
  segmentsRef.current = value;
1232
1431
  setSegments(value);
@@ -1251,6 +1450,91 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1251
1450
  };
1252
1451
  }, [binding === null ? '' : binding.id, binding === null ? '' : binding.cwd]);
1253
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
+
1254
1538
  // Focus a just-created or restored card ------------------------------
1255
1539
  useEffect(() => {
1256
1540
  const pending = pendingFocusRef.current;
@@ -1296,6 +1580,8 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1296
1580
  window.removeEventListener('keydown', onKeyDown, true);
1297
1581
  clearTimer();
1298
1582
  if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
1583
+ for (const timer of imageDeleteTimersRef.current.values()) clearTimeout(timer);
1584
+ imageDeleteTimersRef.current.clear();
1299
1585
  };
1300
1586
  }, []);
1301
1587
 
@@ -1334,11 +1620,13 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1334
1620
  .then((data) => {
1335
1621
  if (rootRef.current !== root || dirtyRef.current) return;
1336
1622
  if (typeof data.mtimeMs === 'number' && data.mtimeMs === versionRef.current) return;
1337
- let value = normalizeServerSegments(data);
1338
- 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);
1339
1627
  segmentsRef.current = value;
1340
1628
  setSegments(value);
1341
- setSelection(new Set());
1629
+ pruneSelectionFor(value);
1342
1630
  versionRef.current = typeof data.mtimeMs === 'number' ? data.mtimeMs : null;
1343
1631
  setSavedAt(typeof data.mtimeMs === 'number' ? data.mtimeMs : null);
1344
1632
  setStatus('saved');
@@ -1371,6 +1659,19 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1371
1659
  scheduleSave();
1372
1660
  };
1373
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
+
1374
1675
  // Image attachments ---------------------------------------------------
1375
1676
  const imageErrorText = (reason) => {
1376
1677
  const code = reason?.code;
@@ -1445,13 +1746,46 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1445
1746
  }
1446
1747
  };
1447
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
+
1448
1779
  const removeImage = (id, pathValue) => {
1449
1780
  const next = segmentsRef.current.map((segment) => {
1450
1781
  if (segment.id !== id) return segment;
1451
1782
  const images = Array.isArray(segment.images) ? segment.images.filter((image) => image.path !== pathValue) : [];
1452
1783
  return { ...segment, images };
1453
1784
  });
1785
+ const stillReferenced = next.some((segment) =>
1786
+ (Array.isArray(segment.images) ? segment.images : []).some((image) => image.path === pathValue));
1454
1787
  commitSegments(next);
1788
+ if (!stillReferenced) deleteImageFile(pathValue);
1455
1789
  };
1456
1790
 
1457
1791
  const triggerFilePicker = (id) => {
@@ -1524,7 +1858,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1524
1858
  return;
1525
1859
  }
1526
1860
  const segment = newSegment('');
1527
- commitSegments([...segmentsRef.current, segment], { focus: { id: segment.id, caret: 0 } });
1861
+ commitSegments([segment, ...segmentsRef.current], { focus: { id: segment.id, caret: 0 } });
1528
1862
  };
1529
1863
 
1530
1864
  const splitSegment = (id, element) => {
@@ -1534,15 +1868,21 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1534
1868
  const head = current.text.slice(0, start);
1535
1869
  const tail = current.text.slice(start);
1536
1870
  const nextSegment = newSegment(tail);
1537
- const next = [];
1538
- for (const segment of segmentsRef.current) {
1539
- if (segment.id === id) {
1540
- next.push({ ...segment, text: head });
1541
- next.push(nextSegment);
1542
- } else {
1543
- next.push(segment);
1544
- }
1545
- }
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
+ })();
1546
1886
  commitSegments(next, { focus: { id: nextSegment.id, caret: 0 } });
1547
1887
  };
1548
1888
 
@@ -1594,6 +1934,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1594
1934
  const record = undoRef.current;
1595
1935
  if (record === null || record === undefined) return;
1596
1936
  undoRef.current = null;
1937
+ clearPendingImageDeletes();
1597
1938
  commitSegments(record.snapshot);
1598
1939
  setToast(null);
1599
1940
  };
@@ -1604,6 +1945,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1604
1945
  undoRef.current = { snapshot: segmentsRef.current };
1605
1946
  const next = segmentsRef.current.filter((segment) => segment.id !== id);
1606
1947
  commitSegments(next);
1948
+ scheduleImageCleanup(target.images);
1607
1949
  showToast(t('deletedToast'), { label: t('undo'), onClick: undoLast });
1608
1950
  };
1609
1951
 
@@ -1613,9 +1955,226 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1613
1955
  undoRef.current = { snapshot: segmentsRef.current };
1614
1956
  const next = segmentsRef.current.filter((segment) => segment.archived !== true);
1615
1957
  commitSegments(next);
1958
+ scheduleImageCleanup(archived.flatMap((segment) => (Array.isArray(segment.images) ? segment.images : [])));
1616
1959
  showToast(t('clearedArchive', { n: archived.length }), { label: t('undo'), onClick: undoLast });
1617
1960
  };
1618
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
+
1619
2178
  // Selection -----------------------------------------------------------
1620
2179
  const toggleSelected = (id) => {
1621
2180
  const next = new Set(selectedRef.current);
@@ -1902,6 +2461,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1902
2461
  const selectedActive = activeSegments.filter((segment) => selected.has(segment.id));
1903
2462
  const selectedCount = selectedActive.length;
1904
2463
  const allSelected = activeSegments.length > 0 && selectedCount === activeSegments.length;
2464
+ const unsentCount = countUnsent(segments);
1905
2465
  const totalChars = segments.reduce((sum, segment) => sum + segment.text.length, 0);
1906
2466
  const displayWidth = ui.width;
1907
2467
  const displayHeight = ui.minimized ? HEAD_H : ui.height;
@@ -2045,6 +2605,35 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2045
2605
  },
2046
2606
  h(SendIcon, { size: 14 }),
2047
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
+ ),
2048
2637
  h(
2049
2638
  'button',
2050
2639
  {
@@ -2139,6 +2728,14 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2139
2728
  'data-seg-id': segment.id,
2140
2729
  'data-archived': 'false',
2141
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),
2142
2739
  },
2143
2740
  h('input', {
2144
2741
  key: 'file-input',
@@ -2170,8 +2767,47 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2170
2767
  onChange: () => toggleSelected(segment.id),
2171
2768
  }),
2172
2769
  ),
2173
- h('span', { className: 'dsh-np-index' }, String(index + 1).padStart(2, '0')),
2174
- 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
+ }),
2175
2811
  h(
2176
2812
  'button',
2177
2813
  {
@@ -2219,6 +2855,21 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2219
2855
  },
2220
2856
  h(SendIcon, { size: 14 }),
2221
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
+ ),
2222
2873
  h(
2223
2874
  'button',
2224
2875
  {
@@ -2336,6 +2987,9 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2336
2987
  'div',
2337
2988
  { className: 'dsh-np-seg-head' },
2338
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,
2339
2993
  h('span', { className: 'dsh-np-spacer' }),
2340
2994
  h(
2341
2995
  'button',
@@ -2502,6 +3156,12 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2502
3156
  onClick: toggleHidden,
2503
3157
  },
2504
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,
2505
3165
  );
2506
3166
 
2507
3167
  return [card, restore];