dsh-composer-rewind 2.0.3 → 2.1.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.
Files changed (3) hide show
  1. package/README.md +13 -2
  2. package/client.js +456 -22
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -8,6 +8,8 @@ Composer Rewind 是 DeepSeek Harness Web UI 的输入框状态历史插件。它
8
8
 
9
9
  - **完整记录**:输入框里的文字、图片和文件附件、@ 引用、/ 指令,以及当时的 DSH 配置。
10
10
  - **完整恢复**:把一条历史还原成记录时的输入框,包括附件缩略图、引用与指令。
11
+ - **按会话归档**:历史跟着会话走,面板和 `Ctrl+Alt+Z` 只显示当前会话的记录,切换会话自动切换视图。
12
+ - **独立管理页**:在 Harness 设置的“输入框历史”里按会话浏览、删除、清空或导出全部记录。
11
13
  - **不打断输入**:停顿约 450ms 才写入一次,连续打字只更新同一条草稿,不会堆出逐字记录。
12
14
  - **快速找回**:搜索、方向键选择、回车还原;`Ctrl+Alt+Z` 连续回溯更早的输入内容。
13
15
  - **原生外观**:复用 Harness 加号菜单的浮层外壳与主题 token,自动适配深色和浅色模式。
@@ -33,17 +35,26 @@ Composer Rewind 是 DeepSeek Harness Web UI 的输入框状态历史插件。它
33
35
 
34
36
  ## 使用
35
37
 
36
- 点击输入框左侧工具栏的时钟按钮打开历史面板。
38
+ 点击输入框左侧工具栏的时钟按钮打开历史面板。面板只显示**当前会话**的记录。
37
39
 
38
40
  - 顶部搜索框可按文字、附件名或配置筛选。
39
41
  - 记录按“当前草稿 / 今天 / 昨天 / 更早”分组。
40
42
  - `↑` / `↓` 选择,`Enter` 还原,`Esc` 关闭。
41
43
  - 鼠标悬停可查看完整文字、附件与当时的配置;悬浮卡会自动避开屏幕边缘。
42
44
  - 每条历史记录支持还原、复制、删除;“清空”会删除全部历史与附件缓存。
43
- - 在输入框中按 `Ctrl+Alt+Z` 可直接回溯插入上一条,连续按下继续往前。
45
+ - 在输入框中按 `Ctrl+Alt+Z` 可直接回溯插入当前会话的上一条,连续按下继续往前。
44
46
 
45
47
  还原只写入输入框,**不会自动发送**,可以继续修改后再提交。
46
48
 
49
+ ## 设置页
50
+
51
+ Harness 设置中新增“输入框历史”页面,用于跨会话管理记录:
52
+
53
+ - 顶部显示会话数、记录数、附件数与本插件占用的浏览器存储。
54
+ - 记录按会话分组,标出当前会话;每组可单独清空,也可逐条删除。
55
+ - 可一键清空全部记录,或导出 JSON。导出只含文字与元数据,不包含附件文件。
56
+ - 设置页不提供“还原”,需要还原时回到对应会话,用输入框左侧的历史面板。
57
+
47
58
  ## 恢复范围
48
59
 
49
60
  还原一条记录时,插件按以下顺序恢复:
package/client.js CHANGED
@@ -84,6 +84,33 @@ window.__ModuleLoader__.load({
84
84
  failPermission: '权限',
85
85
  failPreset: '使用模式',
86
86
  failSettings: '配置',
87
+ settingsNav: '输入框历史',
88
+ settingsTitle: '输入框历史',
89
+ settingsHint: '按会话查看和管理已保存的输入内容、附件与配置。',
90
+ settingsStatSessions: '会话',
91
+ settingsStatEntries: '记录',
92
+ settingsStatAttachments: '附件',
93
+ settingsStatUsage: '站点存储',
94
+ settingsCurrentSession: '当前会话',
95
+ settingsExport: '导出 JSON',
96
+ settingsExportHint: '只导出文字与元数据,不含附件文件。',
97
+ settingsClearAll: '清空全部',
98
+ settingsClearAllConfirm: '确认清空全部?',
99
+ settingsClearSessionConfirm: '确认清空?',
100
+ settingsClearSession: '清空此会话',
101
+ settingsExpand: '展开全部',
102
+ settingsCollapse: '收起',
103
+ settingsEmpty: '还没有任何会话记录。',
104
+ settingsNoText: '(仅附件)',
105
+ settingsUnknownSession: '未关联会话',
106
+ settingsEntryCount: '条记录',
107
+ settingsAttachmentsUnit: '个附件',
108
+ settingsLastAt: '最后',
109
+ settingsDeleted: '已删除',
110
+ settingsSessionCleared: '该会话记录已清空',
111
+ settingsAllCleared: '全部记录已清空',
112
+ settingsExported: '已开始导出',
113
+ settingsExportEmpty: '没有可导出的记录',
87
114
  },
88
115
  en: {
89
116
  trigger: 'Composer history',
@@ -142,6 +169,33 @@ window.__ModuleLoader__.load({
142
169
  failPermission: 'permission',
143
170
  failPreset: 'mode',
144
171
  failSettings: 'setup',
172
+ settingsNav: 'Composer history',
173
+ settingsTitle: 'Composer history',
174
+ settingsHint: 'Browse and manage the contents, attachments and setup saved per session.',
175
+ settingsStatSessions: 'Sessions',
176
+ settingsStatEntries: 'Entries',
177
+ settingsStatAttachments: 'Attachments',
178
+ settingsStatUsage: 'Site storage',
179
+ settingsCurrentSession: 'Current session',
180
+ settingsExport: 'Export JSON',
181
+ settingsExportHint: 'Text and metadata only; attachment files are not included.',
182
+ settingsClearAll: 'Clear all',
183
+ settingsClearAllConfirm: 'Clear everything?',
184
+ settingsClearSessionConfirm: 'Clear this session?',
185
+ settingsClearSession: 'Clear session',
186
+ settingsExpand: 'Show all',
187
+ settingsCollapse: 'Show less',
188
+ settingsEmpty: 'No session history yet.',
189
+ settingsNoText: '(attachments only)',
190
+ settingsUnknownSession: 'Unlinked session',
191
+ settingsEntryCount: 'entries',
192
+ settingsAttachmentsUnit: 'attachments',
193
+ settingsLastAt: 'last',
194
+ settingsDeleted: 'Deleted',
195
+ settingsSessionCleared: 'Session history cleared',
196
+ settingsAllCleared: 'All history cleared',
197
+ settingsExported: 'Export started',
198
+ settingsExportEmpty: 'Nothing to export',
145
199
  },
146
200
  };
147
201
 
@@ -589,6 +643,11 @@ window.__ModuleLoader__.load({
589
643
  commitEntries([]);
590
644
  }
591
645
 
646
+ function clearSession(sessionId) {
647
+ const key = sessionId || '';
648
+ commitEntries(entries.filter((item) => (item.sessionId || '') !== key));
649
+ }
650
+
592
651
  function patchEntryAttachments(id, info) {
593
652
  if (!Array.isArray(info) || info.length === 0) return;
594
653
  const byKey = new Map(info.map((item) => [item.key, item]));
@@ -1007,10 +1066,27 @@ window.__ModuleLoader__.load({
1007
1066
  let lastState = null;
1008
1067
  let lastCapture = null;
1009
1068
 
1069
+ const sessionListeners = new Set();
1070
+
1071
+ function snapshotSession() {
1072
+ return activeSessionId;
1073
+ }
1074
+
1075
+ function subscribeSession(listener) {
1076
+ sessionListeners.add(listener);
1077
+ return () => sessionListeners.delete(listener);
1078
+ }
1079
+
1080
+ function setActiveSession(sessionId) {
1081
+ if (!sessionId || sessionId === activeSessionId) return;
1082
+ activeSessionId = sessionId;
1083
+ for (const listener of sessionListeners) listener();
1084
+ }
1085
+
1010
1086
  function publishComposer(sessionId, inputActions) {
1011
1087
  if (!sessionId || !inputActions) return;
1012
1088
  composerBySession.set(sessionId, inputActions);
1013
- activeSessionId = sessionId;
1089
+ setActiveSession(sessionId);
1014
1090
  }
1015
1091
 
1016
1092
  function composerActions() {
@@ -1303,6 +1379,10 @@ window.__ModuleLoader__.load({
1303
1379
  return useSyncExternalStore(subscribe, snapshotEntries, snapshotEntries);
1304
1380
  }
1305
1381
 
1382
+ function useActiveSession() {
1383
+ return useSyncExternalStore(subscribeSession, snapshotSession, snapshotSession);
1384
+ }
1385
+
1306
1386
  function useLive() {
1307
1387
  return useSyncExternalStore(subscribe, snapshotLive, snapshotLive);
1308
1388
  }
@@ -1453,6 +1533,61 @@ window.__ModuleLoader__.load({
1453
1533
  return pieces.join(' ').toLowerCase();
1454
1534
  }
1455
1535
 
1536
+ function setupLine(settings) {
1537
+ if (!settings) return '';
1538
+ const parts = [];
1539
+ if (settings.model && settings.model.model) parts.push(settings.model.model);
1540
+ if (settings.permission) parts.push(permissionLabel(settings.permission));
1541
+ if (settings.agentPreset) parts.push(presetLabel(settings.agentPreset));
1542
+ return parts.join(' · ');
1543
+ }
1544
+
1545
+ function sessionTitle(list) {
1546
+ for (const entry of list) {
1547
+ const text = (entry.text || '').replace(/\s+/g, ' ').trim();
1548
+ if (text) return text.length > 30 ? text.slice(0, 30) + '…' : text;
1549
+ }
1550
+ return t('settingsNoText');
1551
+ }
1552
+
1553
+ function sessionGroups(list) {
1554
+ const map = new Map();
1555
+ for (const entry of list) {
1556
+ const id = entry.sessionId || '';
1557
+ if (!map.has(id)) map.set(id, { id, entries: [], at: 0, attachments: 0 });
1558
+ const group = map.get(id);
1559
+ group.entries.push(entry);
1560
+ group.at = Math.max(group.at, entry.at || 0);
1561
+ group.attachments += (entry.attachments || []).length;
1562
+ }
1563
+ return [...map.values()].sort((a, b) => b.at - a.at);
1564
+ }
1565
+
1566
+ function exportHistory() {
1567
+ if (entries.length === 0) {
1568
+ toast(t('settingsExportEmpty'));
1569
+ return;
1570
+ }
1571
+ try {
1572
+ const payload = {
1573
+ plugin: 'dsh-composer-rewind',
1574
+ schema: 3,
1575
+ exportedAt: new Date().toISOString(),
1576
+ entries,
1577
+ };
1578
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
1579
+ const url = URL.createObjectURL(blob);
1580
+ const anchor = document.createElement('a');
1581
+ anchor.href = url;
1582
+ anchor.download = 'composer-rewind-' + new Date().toISOString().slice(0, 10) + '.json';
1583
+ anchor.click();
1584
+ window.setTimeout(() => URL.revokeObjectURL(url), 1000);
1585
+ toast(t('settingsExported'));
1586
+ } catch {
1587
+ toast(t('copyFailed'));
1588
+ }
1589
+ }
1590
+
1456
1591
  /* ------------------------------------------------------------------ *
1457
1592
  * Styles
1458
1593
  * ------------------------------------------------------------------ */
@@ -1740,6 +1875,68 @@ window.__ModuleLoader__.load({
1740
1875
  line-height:14px!important;color:var(--dsw-alias-label-tertiary)!important;
1741
1876
  background:color-mix(in srgb,var(--dsw-alias-label-primary) 6%,transparent)!important}
1742
1877
 
1878
+ /* Settings page: follows the harness settings card language. */
1879
+ .pv-set{display:flex;flex-direction:column;gap:16px;width:100%;max-width:720px;margin:0 auto;
1880
+ padding:4px 0 24px;box-sizing:border-box;color:var(--dsw-alias-label-primary);
1881
+ font-size:13px;line-height:1.5;text-align:left}
1882
+ .pv-set *{box-sizing:border-box}
1883
+ .pv-set-head h2{margin:0;font-size:16px;font-weight:600;line-height:1.4}
1884
+ .pv-set-head p{margin:4px 0 0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
1885
+ .pv-set-stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
1886
+ .pv-set-stat{display:flex;flex-direction:column;gap:2px;min-width:0;padding:10px 12px;
1887
+ border:1px solid var(--dsw-alias-border-l1);border-radius:var(--dsw-radius-lg,12px);
1888
+ background:var(--dsw-alias-bg-layer-1)}
1889
+ .pv-set-stat strong{font-size:16px;font-weight:600;line-height:1.3;font-variant-numeric:tabular-nums}
1890
+ .pv-set-stat span{font-size:11px;line-height:1.4;color:var(--dsw-alias-label-tertiary);
1891
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1892
+ .pv-set-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
1893
+ .pv-set-actions-note{flex:1 1 220px;min-width:0;font-size:11px;color:var(--dsw-alias-label-tertiary)}
1894
+ .pv-set-btn{height:28px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);
1895
+ border-radius:var(--dsw-radius-md,8px);background:transparent;color:var(--dsw-alias-label-primary);
1896
+ font-size:12px;line-height:26px;cursor:pointer;white-space:nowrap}
1897
+ .pv-set-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}
1898
+ .pv-set-btn[data-danger="true"]{color:var(--dsw-alias-state-error-primary)}
1899
+ .pv-set-btn-small{height:24px;padding:0 8px;font-size:11px;line-height:22px}
1900
+ .pv-set-groups{display:flex;flex-direction:column;gap:10px}
1901
+ .pv-set-group{border:1px solid var(--dsw-alias-border-l1);border-radius:var(--dsw-radius-lg,12px);
1902
+ background:var(--dsw-alias-bg-layer-1);overflow:hidden}
1903
+ .pv-set-group-head{display:flex;align-items:center;gap:8px;padding:9px 12px;
1904
+ border-bottom:1px solid var(--dsw-alias-border-l1)}
1905
+ .pv-set-group-title{display:flex;align-items:center;gap:6px;flex:1;min-width:0}
1906
+ .pv-set-group-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
1907
+ font-size:13px;font-weight:500}
1908
+ .pv-set-current{flex:none;padding:0 5px;border-radius:4px;font-size:10px;line-height:16px;
1909
+ color:var(--dsw-alias-brand-primary);
1910
+ background:color-mix(in srgb,var(--dsw-alias-brand-primary) 12%,transparent)}
1911
+ .pv-set-group-meta{flex:none;max-width:44%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
1912
+ font-size:11px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
1913
+ .pv-set-list{list-style:none;margin:0;padding:0}
1914
+ .pv-set-row{display:flex;align-items:flex-start;gap:8px;padding:9px 12px;
1915
+ border-top:1px solid var(--dsw-alias-border-l1)}
1916
+ .pv-set-row:first-child{border-top:0}
1917
+ .pv-set-row:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary) 4%,transparent)}
1918
+ .pv-set-row-main{flex:1;min-width:0}
1919
+ .pv-set-row-text{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;
1920
+ white-space:pre-wrap;overflow-wrap:anywhere;font-size:13px;line-height:1.5}
1921
+ .pv-set-row-meta{display:flex;gap:8px;flex-wrap:wrap;margin-top:3px;font-size:11px;
1922
+ color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
1923
+ .pv-set-remove{flex:none;width:24px;height:24px;padding:0;border:0;border-radius:5px;
1924
+ background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;
1925
+ display:inline-flex;align-items:center;justify-content:center}
1926
+ .pv-set-remove:hover{color:var(--dsw-alias-state-error-primary);
1927
+ background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}
1928
+ .pv-set-more{width:100%;height:28px;border:0;border-top:1px solid var(--dsw-alias-border-l1);
1929
+ background:transparent;color:var(--dsw-alias-label-tertiary);font-size:11px;cursor:pointer}
1930
+ .pv-set-more:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
1931
+ .pv-set-empty{display:flex;flex-direction:column;align-items:center;gap:8px;padding:42px 16px;
1932
+ color:var(--dsw-alias-label-tertiary);border:1px solid var(--dsw-alias-border-l1);
1933
+ border-radius:var(--dsw-radius-lg,12px);background:var(--dsw-alias-bg-layer-1)}
1934
+ .pv-set-empty p{margin:0;font-size:12px}
1935
+ @media (max-width:560px){
1936
+ .pv-set-stats{grid-template-columns:repeat(2,minmax(0,1fr))}
1937
+ .pv-set-group-meta{display:none}
1938
+ }
1939
+
1743
1940
  @keyframes pv-fade{from{opacity:0}to{opacity:1}}
1744
1941
  @keyframes pv-pop{from{opacity:0;transform:translateY(4px) scale(.994)}to{opacity:1;transform:none}}
1745
1942
  @media (prefers-reduced-motion:reduce){
@@ -2088,7 +2285,6 @@ window.__ModuleLoader__.load({
2088
2285
  stateRef.current = { state, sessionId };
2089
2286
  lastState = { state, sessionId };
2090
2287
  lastDraft = draft;
2091
- activeSessionId = sessionId || activeSessionId;
2092
2288
 
2093
2289
  useEffect(() => {
2094
2290
  if (!sessionId) return;
@@ -2196,16 +2392,32 @@ window.__ModuleLoader__.load({
2196
2392
  let panelFrame = null;
2197
2393
 
2198
2394
  function measurePanelFrame() {
2199
- if (typeof window === 'undefined') return { left: 12, width: 360, bottom: 96, maxHeight: 420 };
2395
+ if (typeof window === 'undefined') return { left: 12, width: 360, bottom: 96, maxHeight: 400 };
2200
2396
  const node = typeof document !== 'undefined' ? document.querySelector('.pv-trigger') : null;
2201
- const rect = node && node.getBoundingClientRect ? node.getBoundingClientRect() : null;
2202
- const width = Math.min(860, Math.max(320, window.innerWidth - 48));
2203
- const left = rect
2204
- ? Math.max(12, Math.min(rect.left, window.innerWidth - width - 12))
2205
- : Math.max(12, (window.innerWidth - width) / 2);
2206
- const bottom = rect ? Math.max(12, window.innerHeight - rect.top + 6) : 96;
2207
- const above = rect ? rect.top - 24 : window.innerHeight - 140;
2208
- return { left, width, bottom, maxHeight: Math.max(160, Math.min(320, above)) };
2397
+ // The `+` menu is anchored to the composer card, not to the button:
2398
+ // same left edge, same width, 4px above the card.
2399
+ const card = node && node.closest ? node.closest('[data-composer-card]') : null;
2400
+ const rim = card || node;
2401
+ const rect = rim && rim.getBoundingClientRect ? rim.getBoundingClientRect() : null;
2402
+ if (!rect) return { left: 12, width: Math.min(360, window.innerWidth - 24), bottom: 96, maxHeight: 400 };
2403
+ const width = Math.max(240, Math.min(rect.width, window.innerWidth - 24));
2404
+ const left = Math.max(12, Math.min(rect.left, window.innerWidth - width - 12));
2405
+ const bottom = Math.max(4, window.innerHeight - rect.top + 4);
2406
+ // MenuView uses a 400px cap and keeps 84px of viewport above itself.
2407
+ const above = rect.top - 84;
2408
+ return { left, width, bottom, maxHeight: Math.max(160, Math.min(400, above)) };
2409
+ }
2410
+
2411
+ /**
2412
+ * The host `+` menu treats pointerdowns inside the composer card as its
2413
+ * own and stays open. Toggle it closed before we show the history panel,
2414
+ * otherwise both surfaces occupy the exact same frame.
2415
+ */
2416
+ function dismissHostMenu() {
2417
+ if (typeof document === 'undefined') return;
2418
+ const menu = document.querySelector('[data-trigger-menu]');
2419
+ if (!menu || typeof document.body.dispatchEvent !== 'function') return;
2420
+ document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true }));
2209
2421
  }
2210
2422
 
2211
2423
  function placeHoverCard(rect, width, height) {
@@ -2240,7 +2452,8 @@ window.__ModuleLoader__.load({
2240
2452
  onClick: () => {
2241
2453
  publishComposer(props.sessionId, props.inputActions);
2242
2454
  panelFrame = open ? null : measurePanelFrame();
2243
- setOpen(!open);
2455
+ if (!open) dismissHostMenu();
2456
+ setOpen(!open);
2244
2457
  },
2245
2458
  },
2246
2459
  h(TriggerIcon, { size: 18 }),
@@ -2251,22 +2464,33 @@ window.__ModuleLoader__.load({
2251
2464
  * Panel
2252
2465
  * ------------------------------------------------------------------ */
2253
2466
 
2254
- const recall = { index: -1, at: 0, text: '' };
2467
+ const recall = { index: -1, at: 0, text: '', sessionId: null };
2468
+
2469
+ function entriesForSession(sessionId) {
2470
+ if (!sessionId) return entries;
2471
+ return entries.filter((entry) => entry.sessionId === sessionId);
2472
+ }
2255
2473
 
2256
2474
  function recallPrevious() {
2257
- if (entries.length === 0) {
2475
+ const source = entriesForSession(activeSessionId);
2476
+ if (source.length === 0) {
2258
2477
  toast(t('noHistory'));
2259
2478
  return;
2260
2479
  }
2261
2480
  const now = Date.now();
2262
- const sameSession = now - recall.at < 6000 && recall.text !== '' && normalize(currentDraft()) === recall.text;
2263
- const index = sameSession ? Math.min(entries.length - 1, recall.index + 1) : 0;
2264
- const entry = entries[index];
2481
+ const sameSession =
2482
+ now - recall.at < 6000 &&
2483
+ recall.text !== '' &&
2484
+ recall.sessionId === activeSessionId &&
2485
+ normalize(currentDraft()) === recall.text;
2486
+ const index = sameSession ? Math.min(source.length - 1, recall.index + 1) : 0;
2487
+ const entry = source[index];
2265
2488
  if (insertText(entry.text)) {
2266
2489
  recall.index = index;
2267
2490
  recall.at = now;
2268
2491
  recall.text = entry.text;
2269
- toast(t('inserted') + ' ' + (index + 1) + '/' + entries.length);
2492
+ recall.sessionId = activeSessionId;
2493
+ toast(t('inserted') + ' ' + (index + 1) + '/' + source.length);
2270
2494
  } else {
2271
2495
  toast(t('noComposer'));
2272
2496
  }
@@ -2293,6 +2517,7 @@ window.__ModuleLoader__.load({
2293
2517
  const open = useUiOpen();
2294
2518
  const history = useEntries();
2295
2519
  const liveDraft = useLive();
2520
+ const activeSession = useActiveSession();
2296
2521
  const scheme = useScheme();
2297
2522
  const [query, setQuery] = useState('');
2298
2523
  const [activeId, setActiveId] = useState(null);
@@ -2314,11 +2539,13 @@ window.__ModuleLoader__.load({
2314
2539
  : 0;
2315
2540
  const isCurrent =
2316
2541
  !!liveDraft &&
2317
- liveDraft.sessionId === activeSessionId &&
2542
+ liveDraft.sessionId === activeSession &&
2318
2543
  normalize(liveDraft.text) === normalize(currentDraft()) &&
2319
2544
  (liveDraft.attachments || []).length === liveCount;
2320
2545
  const draftRow =
2321
- liveDraft && (liveDraft.text || (liveDraft.attachments || []).length > 0)
2546
+ liveDraft &&
2547
+ (!activeSession || liveDraft.sessionId === activeSession) &&
2548
+ (liveDraft.text || (liveDraft.attachments || []).length > 0)
2322
2549
  ? {
2323
2550
  id: '__live',
2324
2551
  text: liveDraft.text,
@@ -2335,11 +2562,14 @@ window.__ModuleLoader__.load({
2335
2562
  // both "current draft" and the newest history entry. Show it once; the
2336
2563
  // history copy reappears as soon as the composer no longer holds it.
2337
2564
  const liveSignature = draftRow ? entrySignature(draftRow.text, draftRow.attachments) : null;
2338
- const list = history.filter((entry) => !liveSignature || entrySignature(entry.text, entry.attachments) !== liveSignature);
2565
+ // Entries belong to the session they were written in; keep the panel
2566
+ // and Ctrl+Alt+Z scoped to the session that owns the composer now.
2567
+ const scoped = activeSession ? entriesForSession(activeSession) : history;
2568
+ const list = scoped.filter((entry) => !liveSignature || entrySignature(entry.text, entry.attachments) !== liveSignature);
2339
2569
  const matches = needle ? list.filter((entry) => haystack(entry).includes(needle)) : list;
2340
2570
  if (draftRow && (!needle || haystack(draftRow).includes(needle))) return [draftRow, ...matches];
2341
2571
  return matches;
2342
- }, [history, liveDraft, query]);
2572
+ }, [history, liveDraft, query, activeSession]);
2343
2573
 
2344
2574
  const groups = useMemo(() => {
2345
2575
  const order = [];
@@ -2694,6 +2924,195 @@ window.__ModuleLoader__.load({
2694
2924
  return h(React.Fragment, null, panel, h(Toast, { scheme }));
2695
2925
  }
2696
2926
 
2927
+ /* ------------------------------------------------------------------ *
2928
+ * Settings page
2929
+ * ------------------------------------------------------------------ */
2930
+
2931
+ function SettingsPage() {
2932
+ const history = useEntries();
2933
+ const active = useActiveSession();
2934
+ const [confirmAll, setConfirmAll] = useState(false);
2935
+ const [confirmGroup, setConfirmGroup] = useState(null);
2936
+ const [expanded, setExpanded] = useState({});
2937
+ const [usage, setUsage] = useState(null);
2938
+
2939
+ useEffect(() => {
2940
+ const nav = typeof navigator !== 'undefined' ? navigator : null;
2941
+ if (!nav || !nav.storage || typeof nav.storage.estimate !== 'function') return undefined;
2942
+ let alive = true;
2943
+ nav.storage.estimate().then(
2944
+ (value) => {
2945
+ if (alive) setUsage(value && typeof value.usage === 'number' ? value.usage : null);
2946
+ },
2947
+ () => {},
2948
+ );
2949
+ return () => {
2950
+ alive = false;
2951
+ };
2952
+ }, [history]);
2953
+
2954
+ const groups = useMemo(() => sessionGroups(history), [history]);
2955
+ const attachmentCount = history.reduce((sum, entry) => sum + (entry.attachments || []).length, 0);
2956
+
2957
+ const clearGroup = (id) => {
2958
+ if (confirmGroup !== id) {
2959
+ setConfirmGroup(id);
2960
+ return;
2961
+ }
2962
+ clearSession(id);
2963
+ setConfirmGroup(null);
2964
+ setExpanded((value) => ({ ...value, [id]: false }));
2965
+ toast(t('settingsSessionCleared'));
2966
+ };
2967
+
2968
+ const clearEverything = () => {
2969
+ if (!confirmAll) {
2970
+ setConfirmAll(true);
2971
+ return;
2972
+ }
2973
+ clearAll();
2974
+ setConfirmAll(false);
2975
+ setExpanded({});
2976
+ toast(t('settingsAllCleared'));
2977
+ };
2978
+
2979
+ const stat = (label, value) =>
2980
+ h(
2981
+ 'div',
2982
+ { className: 'pv-set-stat', key: label },
2983
+ h('strong', null, value),
2984
+ h('span', null, label),
2985
+ );
2986
+
2987
+ return h(
2988
+ 'div',
2989
+ { className: 'pv-set' },
2990
+ h(
2991
+ 'header',
2992
+ { className: 'pv-set-head' },
2993
+ h('h2', null, t('settingsTitle')),
2994
+ h('p', null, t('settingsHint')),
2995
+ ),
2996
+ h(
2997
+ 'div',
2998
+ { className: 'pv-set-stats' },
2999
+ stat(t('settingsStatSessions'), String(groups.length)),
3000
+ stat(t('settingsStatEntries'), String(history.length)),
3001
+ stat(t('settingsStatAttachments'), String(attachmentCount)),
3002
+ stat(t('settingsStatUsage'), usage == null ? '—' : usage === 0 ? '0 B' : formatBytes(usage)),
3003
+ ),
3004
+ h(
3005
+ 'div',
3006
+ { className: 'pv-set-actions' },
3007
+ h('span', { className: 'pv-set-actions-note' }, t('settingsExportHint')),
3008
+ h(
3009
+ 'button',
3010
+ { type: 'button', className: 'pv-set-btn', onClick: exportHistory },
3011
+ t('settingsExport'),
3012
+ ),
3013
+ h(
3014
+ 'button',
3015
+ {
3016
+ type: 'button',
3017
+ className: 'pv-set-btn',
3018
+ 'data-danger': 'true',
3019
+ onClick: clearEverything,
3020
+ },
3021
+ confirmAll ? t('settingsClearAllConfirm') : t('settingsClearAll'),
3022
+ ),
3023
+ ),
3024
+ groups.length === 0
3025
+ ? h('div', { className: 'pv-set-empty' }, h(Icon, { paths: ACTION_PATHS.search, size: 20 }), h('p', null, t('settingsEmpty')))
3026
+ : h(
3027
+ 'div',
3028
+ { className: 'pv-set-groups' },
3029
+ groups.map((group) => {
3030
+ const openGroup = !!expanded[group.id];
3031
+ const shown = openGroup ? group.entries : group.entries.slice(0, 6);
3032
+ return h(
3033
+ 'section',
3034
+ { className: 'pv-set-group', key: group.id || 'unknown' },
3035
+ h(
3036
+ 'div',
3037
+ { className: 'pv-set-group-head' },
3038
+ h(
3039
+ 'div',
3040
+ { className: 'pv-set-group-title' },
3041
+ h('span', { className: 'pv-set-group-name' }, group.id ? sessionTitle(group.entries) : t('settingsUnknownSession')),
3042
+ group.id === active && group.id ? h('span', { className: 'pv-set-current' }, t('settingsCurrentSession')) : null,
3043
+ ),
3044
+ h(
3045
+ 'span',
3046
+ { className: 'pv-set-group-meta' },
3047
+ group.entries.length + ' ' + t('settingsEntryCount') +
3048
+ (group.attachments ? ' · ' + group.attachments + ' ' + t('settingsAttachmentsUnit') : '') +
3049
+ ' · ' + t('settingsLastAt') + ' ' + formatTime(group.at) +
3050
+ (group.id ? ' · #' + group.id.slice(0, 8) : ''),
3051
+ ),
3052
+ h(
3053
+ 'button',
3054
+ {
3055
+ type: 'button',
3056
+ className: 'pv-set-btn pv-set-btn-small',
3057
+ onClick: () => clearGroup(group.id),
3058
+ },
3059
+ confirmGroup === group.id ? t('settingsClearSessionConfirm') : t('settingsClearSession'),
3060
+ ),
3061
+ ),
3062
+ h(
3063
+ 'ul',
3064
+ { className: 'pv-set-list' },
3065
+ shown.map((entry) =>
3066
+ h(
3067
+ 'li',
3068
+ { className: 'pv-set-row', key: entry.id },
3069
+ h(
3070
+ 'div',
3071
+ { className: 'pv-set-row-main' },
3072
+ h('div', { className: 'pv-set-row-text' }, entry.text || t('settingsNoText')),
3073
+ h(
3074
+ 'div',
3075
+ { className: 'pv-set-row-meta' },
3076
+ h('span', null, formatTime(entry.at)),
3077
+ entry.attachments && entry.attachments.length
3078
+ ? h('span', null, entry.attachments.length + ' ' + t('settingsAttachmentsUnit'))
3079
+ : null,
3080
+ setupLine(entry.settings) ? h('span', null, setupLine(entry.settings)) : null,
3081
+ ),
3082
+ ),
3083
+ h(
3084
+ 'button',
3085
+ {
3086
+ type: 'button',
3087
+ className: 'pv-set-remove',
3088
+ 'aria-label': t('remove'),
3089
+ onClick: () => {
3090
+ removeEntry(entry.id);
3091
+ toast(t('settingsDeleted'));
3092
+ },
3093
+ },
3094
+ h(Icon, { paths: ACTION_PATHS.trash, size: 14 }),
3095
+ ),
3096
+ ),
3097
+ ),
3098
+ ),
3099
+ group.entries.length > 6
3100
+ ? h(
3101
+ 'button',
3102
+ {
3103
+ type: 'button',
3104
+ className: 'pv-set-more',
3105
+ onClick: () => setExpanded((value) => ({ ...value, [group.id]: !openGroup })),
3106
+ },
3107
+ openGroup ? t('settingsCollapse') : t('settingsExpand'),
3108
+ )
3109
+ : null,
3110
+ );
3111
+ }),
3112
+ ),
3113
+ );
3114
+ }
3115
+
2697
3116
  function Toast(props) {
2698
3117
  const message = useToast();
2699
3118
  if (!message) return null;
@@ -2713,8 +3132,12 @@ window.__ModuleLoader__.load({
2713
3132
  setLive,
2714
3133
  setLiveSnapshot,
2715
3134
  clearAll,
3135
+ clearSession,
2716
3136
  removeEntry,
2717
3137
  publishComposer,
3138
+ setActiveSession,
3139
+ activeSession: () => activeSessionId,
3140
+ entriesForSession,
2718
3141
  insertText,
2719
3142
  recallPrevious,
2720
3143
  currentDraft,
@@ -2753,6 +3176,17 @@ window.__ModuleLoader__.load({
2753
3176
  ctx.slots.inject('shell.overlay', () =>
2754
3177
  ctx.slots.register({ name: 'shell.overlay', id: 'composer-rewind-panel', order: 90 }, Panel),
2755
3178
  );
3179
+ ctx.slots.inject('settings.section', () =>
3180
+ ctx.slots.register(
3181
+ {
3182
+ name: 'settings.section',
3183
+ id: 'composer-rewind-settings',
3184
+ order: 30,
3185
+ label: () => t('settingsNav'),
3186
+ },
3187
+ SettingsPage,
3188
+ ),
3189
+ );
2756
3190
  // Release object URLs when the page goes away.
2757
3191
  if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
2758
3192
  window.addEventListener('pagehide', () => {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "dsh-composer-rewind",
3
- "version": "2.0.3",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
- "description": "Composer input history for the DeepSeek Harness Web UI: records composer contents, attachments, command tokens and the active DSH setup, then restores the whole composer state.",
5
+ "description": "Per-session composer input history for the DeepSeek Harness Web UI: records composer contents, attachments, command tokens and the active DSH setup, restores the whole composer state, and manages history per session from a settings page.",
6
6
  "keywords": [
7
7
  "dsh",
8
8
  "dsh-plugin",