@heixui/dsh-session-suite 0.4.0 → 0.4.2

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
@@ -128,8 +128,7 @@ window.__ModuleLoader__.load({
128
128
  'dialog.deleteSubagents': 'This will also delete {n} subagent session(s) started by it.',
129
129
  'dialog.deleteWouldRefuse': 'Something here is still running. Stop it first — nothing has been deleted.',
130
130
  'dialog.sessionRunning': 'This session is running. Stop it before deleting.',
131
- 'settings.title': 'Session snapshots',
132
- 'settings.description': 'Workspace checkpoints captured for a session, with disk usage, locking, and restore controls.',
131
+ 'settings.title': 'Session snapshots', 'settings.description': 'Workspace checkpoints captured for a session, with disk usage, locking, and restore controls.',
133
132
  'settings.empty': 'No snapshots yet. A checkpoint is captured at the start of each turn.',
134
133
  'settings.capture': 'Capture now',
135
134
  'settings.capturing': 'Capturing…',
@@ -142,6 +141,10 @@ window.__ModuleLoader__.load({
142
141
  'settings.unlock': 'Unlock',
143
142
  'settings.locked': 'Locked',
144
143
  'settings.budget': 'Keeps the newest {keep} automatic snapshots',
144
+ 'settings.budgetInherited': '+ {n} inherited',
145
+ 'settings.inherited': 'Inherited',
146
+ 'settings.budgetNote':
147
+ 'Only this session\'s own automatic snapshots count against the budget. Snapshots inherited from a parent session (created by a rewind or message delete) are kept separately and are why the list can be longer than {keep}.',
145
148
  'settings.usage': 'Snapshots use {size}',
146
149
  'settings.files': '{count} files',
147
150
  'settings.turn': 'Turn {turn}',
@@ -212,10 +215,45 @@ window.__ModuleLoader__.load({
212
215
  'memory.pendingTitle': 'Suggested memories',
213
216
  'memory.pendingHint': 'Extracted from the last turns. Nothing is stored until you accept it.',
214
217
  'memory.extractionIdle': 'Nothing is waiting. Last check: {reason}',
218
+ 'memory.extractionNotYet':
219
+ 'Nothing is waiting yet. Extraction runs at the end of each turn, and this Host has not completed one since it started — have a short exchange and come back.',
215
220
  'memory.extractionUnknown': 'no attempt recorded since this Host started',
221
+ 'panel.title': 'Session suite',
222
+ 'panel.description': 'Snapshots, long-term memory, and archived sessions. Each row opens its own page.',
223
+ 'panel.snapshots': 'Session snapshots',
224
+ 'panel.snapshotsHint': 'Workspace checkpoints taken when each turn finishes',
225
+ 'panel.memory': 'Long-term memory',
226
+ 'panel.memoryHint': 'Facts, rules, preferences, and past events kept across sessions',
227
+ 'panel.archived': 'Archived sessions',
228
+ 'panel.archivedHint': 'Sessions hidden from the sidebar; restore them here',
229
+ 'panel.sessionsCount': '{n} session(s) with snapshots',
230
+ 'panel.statSessions': 'Sessions',
231
+ 'panel.statSnapshots': 'Snapshots',
232
+ 'panel.statMemories': 'Memories',
233
+ 'panel.statArchived': 'Archived',
234
+ 'panel.statSessionsHint': 'Every workspace, subagents excluded',
235
+ 'panel.statSnapshotsHint': 'Automatic checkpoints across all sessions',
236
+ 'panel.statMemoriesHint': 'Global plus every workspace, archived included',
237
+ 'panel.statArchivedHint': 'Hidden from the sidebar, restorable',
238
+ 'panel.breakdownTitle': 'Where the memories live',
239
+ 'panel.breakdownGlobal': 'Global (shared by every workspace)',
240
+ 'panel.breakdownHint': 'Each workspace keeps its own store; the total above is the sum of these plus the global one.',
241
+ 'panel.repoLink': 'GitHub source — a small encouragement',
242
+ 'panel.noSettingsSeam': 'This Host build does not expose the settings navigation seam, so this row cannot open its page.',
243
+ 'archived.title': 'Archived sessions',
244
+ 'archived.description': 'Archived sessions are hidden from the sidebar. Restoring one puts it back; nothing is deleted.',
245
+ 'archived.empty': 'No archived sessions.',
246
+ 'archived.restore': 'Restore',
247
+ 'archived.restored': 'Restored',
248
+ 'archived.restoreFailed': 'Could not restore this session',
249
+ 'settings.archived': 'Archived',
250
+ 'settings.checkpointCount': '{n} snapshot(s)',
251
+ 'settings.working': 'Working…',
216
252
  'memory.extractionTried': 'tried {n} memory model(s)',
217
253
  'memory.extractionQueued': '{n} queued',
218
254
  'memory.extractionRejected': '{n} rejected as unusable',
255
+ 'memory.extractionEvents': '{n} session event(s) received',
256
+ 'memory.extractionTurns': '{n} turn(s) ended',
219
257
  'memory.accept': 'Keep',
220
258
  'memory.reject': 'Discard',
221
259
  'memory.accepted': 'Kept',
@@ -228,6 +266,11 @@ window.__ModuleLoader__.load({
228
266
  'memory.maintainedCompressed': 'compressed',
229
267
  'memory.maintainedRules': 'rules',
230
268
  'memory.implicit': 'Suggest memories automatically',
269
+ 'memory.implicitModel': 'Model for automatic extraction',
270
+ 'memory.implicitModelOff': 'Off — no model call, costs nothing',
271
+ 'memory.implicitModelCurrent': 'Use the model this conversation is already using',
272
+ 'memory.implicitModelHint':
273
+ 'Automatic extraction sends one small request at the end of each turn. Choosing a model here is what authorises that spend; "Off" and "Use the current model" are the two ends of the tradeoff.',
231
274
  'memory.clearCold': 'Clear cold archive',
232
275
  'memory.coldCleared': 'Cold archive cleared',
233
276
  },
@@ -327,6 +370,10 @@ window.__ModuleLoader__.load({
327
370
  'settings.unlock': '解锁',
328
371
  'settings.locked': '已锁定',
329
372
  'settings.budget': '每个会话保留最近 {keep} 个自动快照',
373
+ 'settings.budgetInherited': '+ {n} 个继承',
374
+ 'settings.inherited': '继承',
375
+ 'settings.budgetNote':
376
+ '只有这个会话自己的自动快照计入预算。从父会话继承来的快照(回滚或删除消息时产生)单独保留,所以列表可能比 {keep} 长。',
330
377
  'settings.usage': '快照占用 {size}',
331
378
  'settings.files': '{count} 个文件',
332
379
  'settings.turn': '第 {turn} 轮',
@@ -397,10 +444,45 @@ window.__ModuleLoader__.load({
397
444
  'memory.pendingTitle': '待确认的记忆',
398
445
  'memory.pendingHint': '从最近几轮里识别出来的,确认前不会入库。',
399
446
  'memory.extractionIdle': '当前没有待确认条目。上次检查结果:{reason}',
447
+ 'memory.extractionNotYet':
448
+ '暂时还没有待确认条目。提取在每轮对话结束时运行,而本次启动后还没完成过任何一轮——聊一两句再回来看。',
400
449
  'memory.extractionUnknown': '本次运行还没有执行过提取',
450
+ 'panel.title': '会话套件',
451
+ 'panel.description': '会话快照、长期记忆、归档会话。每行点开就是对应的页面。',
452
+ 'panel.snapshots': '会话快照',
453
+ 'panel.snapshotsHint': '每轮结束时自动记录的工作区状态',
454
+ 'panel.memory': '长期记忆',
455
+ 'panel.memoryHint': '跨会话保留的事实、规则、偏好与经历',
456
+ 'panel.archived': '归档会话',
457
+ 'panel.archivedHint': '已从侧边栏隐藏的会话,可以在这里恢复',
458
+ 'panel.sessionsCount': '{n} 个会话有快照',
459
+ 'panel.statSessions': '会话',
460
+ 'panel.statSnapshots': '快照',
461
+ 'panel.statMemories': '记忆',
462
+ 'panel.statArchived': '已归档',
463
+ 'panel.statSessionsHint': '所有工作区,不含子代理会话',
464
+ 'panel.statSnapshotsHint': '所有会话的自动快照总数',
465
+ 'panel.statMemoriesHint': '全局 + 各工作区,含已归档',
466
+ 'panel.statArchivedHint': '从侧边栏隐藏,可恢复',
467
+ 'panel.breakdownTitle': '记忆存在哪',
468
+ 'panel.breakdownGlobal': '全局(所有工作区共享)',
469
+ 'panel.breakdownHint': '每个工作区有独立的库;上面的总数 = 这些之和 + 全局。',
470
+ 'panel.repoLink': 'GitHub 源码 - 鼓励一下',
471
+ 'panel.noSettingsSeam': '这个 Host 版本没有暴露设置导航接口,无法打开对应页面。',
472
+ 'archived.title': '归档会话',
473
+ 'archived.description': '归档的会话从侧边栏隐藏。恢复后会重新出现,不会删除任何东西。',
474
+ 'archived.empty': '没有归档的会话。',
475
+ 'archived.restore': '恢复',
476
+ 'archived.restored': '已恢复',
477
+ 'archived.restoreFailed': '恢复失败',
478
+ 'settings.archived': '已归档',
479
+ 'settings.checkpointCount': '{n} 张快照',
480
+ 'settings.working': '处理中…',
401
481
  'memory.extractionTried': '试过 {n} 个记忆模型',
402
482
  'memory.extractionQueued': '入队 {n} 条',
403
483
  'memory.extractionRejected': '剔除 {n} 条',
484
+ 'memory.extractionEvents': '收到 {n} 个会话事件',
485
+ 'memory.extractionTurns': '结束 {n} 轮',
404
486
  'memory.accept': '保留',
405
487
  'memory.reject': '丢弃',
406
488
  'memory.accepted': '已保留',
@@ -413,6 +495,11 @@ window.__ModuleLoader__.load({
413
495
  'memory.maintainedCompressed': '已压缩',
414
496
  'memory.maintainedRules': '条规则',
415
497
  'memory.implicit': '自动识别并建议记忆',
498
+ 'memory.implicitModel': '自动提取使用的模型',
499
+ 'memory.implicitModelOff': '关闭 —— 不调用模型,零消耗',
500
+ 'memory.implicitModelCurrent': '用当前对话正在使用的模型',
501
+ 'memory.implicitModelHint':
502
+ '自动提取会在每轮结束时发一次小请求。在这里选定模型才代表你授权了这笔消耗;「关闭」和「用当前模型」是这笔取舍的两端。',
416
503
  'memory.clearCold': '清空冷归档',
417
504
  'memory.coldCleared': '冷归档已清空',
418
505
  },
@@ -568,6 +655,9 @@ window.__ModuleLoader__.load({
568
655
  h('path', { key: 'd', d: 'M10.2 10.2 4.8 2.6' }),
569
656
  ]),
570
657
  lock: () => icon([h('rect', { key: 'a', x: 3.4, y: 7, width: 9.2, height: 6.4, rx: 1.4 }), h('path', { key: 'b', d: 'M5.6 7V5.4a2.4 2.4 0 0 1 4.8 0V7' })]),
658
+ // A right-pointing chevron: these rows navigate somewhere, so the row's
659
+ // affordance is "go", not "expand".
660
+ chevron: () => icon([h('path', { key: 'a', d: 'M6.2 3.6 10.6 8l-4.4 4.4' })]),
571
661
  unlock: () => icon([h('rect', { key: 'a', x: 3.4, y: 7, width: 9.2, height: 6.4, rx: 1.4 }), h('path', { key: 'b', d: 'M5.6 7V5.4a2.4 2.4 0 0 1 4.6-.8' })]),
572
662
  restore: () => icon([h('path', { key: 'a', d: 'M3 8a5 5 0 1 1 1.6 3.7' }), h('path', { key: 'b', d: 'M2.6 2.8v3h3' })]),
573
663
  close: () => icon([h('path', { key: 'a', d: 'M4 4l8 8' }), h('path', { key: 'b', d: 'M12 4l-8 8' })]),
@@ -725,6 +815,16 @@ window.__ModuleLoader__.load({
725
815
  .dss-curate-title { font-size: 13px; color: var(--dsw-alias-label-primary, #1f1f1f); }
726
816
  .dss-curate-hint { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary, #6b6b6b); }
727
817
  .dss-curate-inline { margin-top: 0; padding: 6px 10px; border-color: transparent; background: transparent; }
818
+ /* The model picker sits below the toggle as a labelled control, because it is a
819
+ separate decision from "on/off": one says whether to extract, the other says who
820
+ pays for it. */
821
+ .dss-curate-field {
822
+ display: flex; flex-direction: column; gap: 6px; margin-top: 10px;
823
+ padding: 12px 14px; border-radius: 8px;
824
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08));
825
+ background: var(--dsw-alias-bg-layer-1, #fff);
826
+ }
827
+ .dss-curate-label { font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary, #1f1f1f); }
728
828
 
729
829
  .dss-tabs { display: flex; gap: 4px; flex-wrap: wrap; }
730
830
  .dss-tab {
@@ -760,6 +860,118 @@ window.__ModuleLoader__.load({
760
860
  .dss-toast.dss-error { background: var(--dsw-alias-state-error-primary, #d93025); }
761
861
 
762
862
  .dss-section { display: flex; flex-direction: column; gap: 12px; max-width: 720px; }
863
+ /* A screen opened from the sidebar panel inherits the panel's scroll container, so it
864
+ must not impose its own height. The settings dialog still renders these sections
865
+ directly and relies on the dialog's own scrolling. */
866
+ /* The sidebar panel is a main-column surface. It must scroll: the memory list is
867
+ dozens of rows, and without an explicit overflow the page clipped at the viewport
868
+ with no way to reach the rest — reported as "no scroll wheel, there are already
869
+ a dozen entries". The min-height 0 is required for the child flex column to shrink
870
+ below its content size, otherwise overflow never triggers. */
871
+ .dss-panel {
872
+ display: flex; flex-direction: column; gap: 14px;
873
+ padding: 24px 28px; max-width: 760px; margin: 0 auto;
874
+ height: 100%; min-height: 0;
875
+ overflow-y: auto; overscroll-behavior: contain;
876
+ }
877
+ .dss-panel h2 { margin: 0; font-size: 17px; font-weight: 600; color: var(--dsw-alias-label-primary, #1f1f1f); }
878
+ .dss-nav { display: flex; flex-direction: column; gap: 10px; }
879
+ /* Rows follow the shipped card idiom: the same border and background tokens
880
+ dsh-connect-trae uses (--dsw-alias-border-l2 / --dsw-alias-bg-l2), so the panel
881
+ looks like part of the app rather than a bolt-on. A left accent bar appears on
882
+ hover and focus instead of a colour swap, which keeps the text readable in both
883
+ themes and gives keyboard users the same affordance as mouse users. */
884
+ .dss-nav-row {
885
+ position: relative;
886
+ display: flex; align-items: center; gap: 14px; width: 100%;
887
+ padding: 14px 16px; text-align: left; cursor: pointer;
888
+ font: inherit; color: inherit;
889
+ background: var(--dsw-alias-bg-l2, #fff);
890
+ border: 1px solid var(--dsw-alias-border-l2, #e3e4e6);
891
+ border-radius: 12px;
892
+ transition: border-color .14s ease, background .14s ease, transform .14s ease;
893
+ }
894
+ .dss-nav-row::before {
895
+ content: ''; position: absolute; left: 0; top: 12px; bottom: 12px; width: 3px;
896
+ border-radius: 0 3px 3px 0;
897
+ background: var(--dsw-alias-brand, #4d6bfe);
898
+ opacity: 0; transition: opacity .14s ease;
899
+ }
900
+ .dss-nav-row:hover, .dss-nav-row:focus-visible {
901
+ border-color: var(--dsw-alias-brand, #4d6bfe);
902
+ background: var(--dsw-alias-bg-l3, #f7f8fa);
903
+ outline: none;
904
+ }
905
+ .dss-nav-row:hover::before, .dss-nav-row:focus-visible::before { opacity: 1; }
906
+ .dss-nav-row:active { transform: translateY(1px); }
907
+ .dss-nav-row > svg { flex: none; color: var(--dsw-alias-label-tertiary, #9a9a9a); }
908
+ .dss-nav-main { display: flex; flex-direction: column; gap: 3px; flex: 1; min-width: 0; }
909
+ .dss-nav-title { font-size: 13.5px; font-weight: 600; color: var(--dsw-alias-label-primary, #1f1f1f); }
910
+ .dss-nav-hint { font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-secondary, #6b6b6b); }
911
+ .dss-nav-meta {
912
+ flex: none; min-width: 28px; text-align: center;
913
+ font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums;
914
+ color: var(--dsw-alias-label-secondary, #6b6b6b);
915
+ background: var(--dsw-alias-bg-l3, #f2f3f5);
916
+ border: 1px solid var(--dsw-alias-border-l2, #e3e4e6);
917
+ border-radius: 999px; padding: 2px 9px;
918
+ }
919
+ .dss-panel-footer {
920
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
921
+ font-size: 12px; color: var(--dsw-alias-label-tertiary, #9a9a9a);
922
+ padding-top: 8px; border-top: 1px solid var(--dsw-alias-border-l3, #f0f0f0);
923
+ }
924
+ .dss-panel-link {
925
+ color: var(--dsw-alias-brand, #4d6bfe);
926
+ text-decoration: none; font-size: 12px;
927
+ }
928
+ .dss-panel-link:hover, .dss-panel-link:focus-visible { text-decoration: underline; }
929
+ /* A four-up summary strip. It fills the column with information rather than
930
+ decoration, and the numbers match the badges on the rows below. */
931
+ .dss-stat-strip {
932
+ display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px;
933
+ }
934
+ .dss-stat {
935
+ display: flex; flex-direction: column; gap: 3px;
936
+ padding: 14px 16px;
937
+ background: var(--dsw-alias-bg-l2, #fff);
938
+ border: 1px solid var(--dsw-alias-border-l2, #e3e4e6);
939
+ border-radius: 12px;
940
+ }
941
+ .dss-stat-value {
942
+ font-size: 22px; font-weight: 650; line-height: 1.15;
943
+ font-variant-numeric: tabular-nums;
944
+ color: var(--dsw-alias-label-primary, #1f1f1f);
945
+ }
946
+ .dss-stat-label {
947
+ font-size: 11.5px; color: var(--dsw-alias-label-secondary, #6b6b6b);
948
+ }
949
+ /* The caption under each figure. Small and tertiary: it explains the number without
950
+ competing with it. */
951
+ .dss-stat-hint {
952
+ font-size: 10.5px; line-height: 1.4;
953
+ color: var(--dsw-alias-label-tertiary, #9a9a9a);
954
+ }
955
+ .dss-breakdown {
956
+ display: flex; flex-direction: column; gap: 6px;
957
+ padding: 14px 16px;
958
+ background: var(--dsw-alias-bg-l3, #f7f8fa);
959
+ border: 1px solid var(--dsw-alias-border-l2, #e3e4e6);
960
+ border-radius: 12px;
961
+ }
962
+ .dss-breakdown-row { display: flex; align-items: baseline; gap: 10px; font-size: 12.5px; }
963
+ .dss-breakdown-key {
964
+ flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
965
+ color: var(--dsw-alias-label-primary, #1f1f1f);
966
+ }
967
+ .dss-breakdown-value {
968
+ flex: none; font-variant-numeric: tabular-nums; font-weight: 600;
969
+ color: var(--dsw-alias-label-secondary, #6b6b6b);
970
+ }
971
+ .dss-breakdown-hint { font-size: 11px; line-height: 1.5; color: var(--dsw-alias-label-tertiary, #9a9a9a); }
972
+ /* The way back from a screen. Left-aligned and small: it is an escape hatch, not
973
+ an action competing with the page's own controls. */
974
+ .dss-panel-nav { display: flex; align-items: center; margin-bottom: -2px; }
763
975
  .dss-section.dss-embedded { max-width: none; gap: 10px; }
764
976
  .dss-section h2 { margin: 0; font-size: 15px; font-weight: 600; color: var(--dsw-alias-label-primary, #1f1f1f); }
765
977
  .dss-section-head { display: flex; flex-direction: column; gap: 4px; }
@@ -769,6 +981,18 @@ window.__ModuleLoader__.load({
769
981
  border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08));
770
982
  border-radius: 10px; background: var(--dsw-alias-bg-layer-1, #fff);
771
983
  }
984
+ /* A bounded list: the library scrolls, the page does not.
985
+ Without this the settings below sat below the fold behind fifty rows, so every
986
+ change meant scrolling past the whole library first. 480px is about eleven rows,
987
+ picked by looking at it rather than by arithmetic — it was raised from 360px after
988
+ the first version read as too short. Single number, retune freely. overscroll-behavior
989
+ contain stops a scroll that reaches the end of the list from continuing into the page,
990
+ which is what makes the box feel like a box rather than a speed bump. */
991
+ .dss-scroll-box {
992
+ max-height: 480px;
993
+ overflow-y: auto;
994
+ overscroll-behavior: contain;
995
+ }
772
996
  .dss-row {
773
997
  display: flex; align-items: center; gap: 10px; padding: 10px 12px;
774
998
  border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.06));
@@ -2072,6 +2296,8 @@ window.__ModuleLoader__.load({
2072
2296
  const [error, setError] = useState(null)
2073
2297
  const [busy, setBusy] = useState(null)
2074
2298
  const [memoryDraft, setMemoryDraft] = useState('')
2299
+ // Kept for the memory.add API path that the long-term screen still uses; the
2300
+ // snapshot panel no longer renders a session-memory editor.
2075
2301
 
2076
2302
  useEffect(() => {
2077
2303
  let alive = true
@@ -2177,15 +2403,31 @@ window.__ModuleLoader__.load({
2177
2403
  )
2178
2404
 
2179
2405
  const stats = detail?.stats
2406
+ // Split the list so the budget makes sense on screen.
2407
+ //
2408
+ // The header said "keeps the newest N automatic snapshots" while the list showed
2409
+ // sixteen, and nothing explained the difference: ten were this session's own and
2410
+ // six were inherited from the parent session by a rewind. Counting inherited
2411
+ // entries against the budget would make a fork delete every snapshot it takes
2412
+ // itself, so they are kept separately — but the user has to be told that, or the
2413
+ // number looks like a bug.
2414
+ const inheritedCount = (detail?.checkpoints ?? []).filter(cp => cp.inheritedFrom).length
2180
2415
  const statsLine = stats
2181
2416
  ? h(
2182
2417
  'div',
2183
2418
  { className: 'dss-row-meta' },
2184
2419
  h('span', null, t('settings.usage', { size: stats.label })),
2185
2420
  h('span', null, t('settings.budget', { keep: stats.budget })),
2421
+ inheritedCount > 0
2422
+ ? h('span', null, t('settings.budgetInherited', { n: inheritedCount }))
2423
+ : null,
2186
2424
  detail.cwd ? h('span', { title: detail.cwd }, detail.cwd) : null,
2187
2425
  )
2188
2426
  : null
2427
+ const budgetNote =
2428
+ inheritedCount > 0
2429
+ ? h('div', { className: 'dss-section-desc' }, t('settings.budgetNote', { keep: stats?.budget ?? 10 }))
2430
+ : null
2189
2431
 
2190
2432
  const checkpoints = detail?.checkpoints ?? []
2191
2433
  const checkpointList =
@@ -2207,6 +2449,10 @@ window.__ModuleLoader__.load({
2207
2449
  h('span', null, cp.label || cp.id),
2208
2450
  cp.locked ? h('span', { className: 'dss-badge dss-locked' }, t('settings.locked')) : null,
2209
2451
  cp.temporary ? h('span', { className: 'dss-badge dss-temp' }, t('settings.temporary')) : null,
2452
+ // Inherited snapshots are a different thing from this session's
2453
+ // own: they came from the parent by a rewind, they do not count
2454
+ // against the budget, and deleting the child does not delete them.
2455
+ cp.inheritedFrom ? h('span', { className: 'dss-badge' }, t('settings.inherited')) : null,
2210
2456
  ),
2211
2457
  h(
2212
2458
  'div',
@@ -2309,87 +2555,402 @@ window.__ModuleLoader__.load({
2309
2555
  ),
2310
2556
  )
2311
2557
 
2312
- const memory = detail?.memory ?? []
2313
- const memoryBlock = h(
2558
+ // The session-memory block was removed deliberately.
2559
+ //
2560
+ // It was a per-session list of loose facts, never written to, sitting next to a
2561
+ // real trajectory view that the Host already provides. Two surfaces for "what
2562
+ // happened in this session" — one authoritative and read-only, one a stub that
2563
+ // nobody fed — invites the reader to trust the stub. The trajectory is the
2564
+ // record of what happened and is not meant to be edited; a hand-written note
2565
+ // beside it is a weaker duplicate. Removed rather than hidden so the panel does
2566
+ // not advertise a capability it does not have.
2567
+
2568
+ return h(
2569
+ 'div',
2570
+ { className: sectionClass },
2571
+ head,
2572
+ picker,
2573
+ statsLine,
2574
+ budgetNote,
2575
+ checkpointList,
2576
+ undoList,
2577
+ )
2578
+ }
2579
+
2580
+ // ------------------------------------------------------- long-term memory
2581
+
2582
+ function useWorkspacePath() {
2583
+ return runtime.resolveCwd?.() ?? runtime.cwd
2584
+ }
2585
+
2586
+ /** Last path segment, for a compact workspace label. */
2587
+ function basename(value) {
2588
+ const text = String(value ?? '')
2589
+ const cut = Math.max(text.lastIndexOf('/'), text.lastIndexOf('\\'))
2590
+ return cut >= 0 ? text.slice(cut + 1) || text : text
2591
+ }
2592
+
2593
+ // --------------------------------------------------------- sidebar panel
2594
+
2595
+ /**
2596
+ * The sidebar icon.
2597
+ *
2598
+ * The owner supplies `size` and `active`, so the glyph has to inherit both
2599
+ * rather than carry its own colour — a tinted icon reads as branding and
2600
+ * clashes with the selected state the shell already draws around it.
2601
+ *
2602
+ * @param {any} props owner props
2603
+ * @returns {any} icon element
2604
+ */
2605
+ function SuiteIcon(props) {
2606
+ const size = typeof props?.size === 'number' ? props.size : 18
2607
+ return h(
2608
+ 'svg',
2609
+ {
2610
+ width: size,
2611
+ height: size,
2612
+ viewBox: '0 0 16 16',
2613
+ fill: 'none',
2614
+ stroke: 'currentColor',
2615
+ strokeWidth: 1.4,
2616
+ strokeLinecap: 'round',
2617
+ strokeLinejoin: 'round',
2618
+ 'aria-hidden': 'true',
2619
+ },
2620
+ // Two stacked frames: this bundle is about sessions kept as snapshots, so
2621
+ // the mark is layers rather than the clock-like arc a "history" icon would
2622
+ // use (and which already means rewind in the action row).
2623
+ h('rect', { key: 'a', x: 2.6, y: 4.4, width: 8.4, height: 8.2, rx: 1.6 }),
2624
+ h('path', { key: 'b', d: 'M5.4 4.3V3.4a1.2 1.2 0 0 1 1.2-1.2h5.4a1.2 1.2 0 0 1 1.2 1.2v6.4' }),
2625
+ )
2626
+ }
2627
+
2628
+ /**
2629
+ * The panel shown when the sidebar icon is selected.
2630
+ *
2631
+ * It renders the three screens IN PLACE rather than navigating to Settings.
2632
+ *
2633
+ * That is not the first design. The first version was a navigation menu whose
2634
+ * rows called the settings shell's `openSection(id)`, copied from a comment in
2635
+ * dsh-connect-trae. It did nothing, and reading the shipped bundles explains why:
2636
+ *
2637
+ * • `createSettingsShellStore()` is created inside
2638
+ * `dsh-client-ui-settings-general` and never published — no `reflect.provide`,
2639
+ * no service name — so no other client plugin can reach `openSection`.
2640
+ * • `pluginNavigation` (the one real navigation service) offers only
2641
+ * `openList()` and `openBundle(name)`, and `openBundle` lands on the bundle
2642
+ * page (`view: { kind: 'package' }`), which renders `plugins.bundle.config`
2643
+ * and bundle rows — it does not render `settings.section` pages at all.
2644
+ *
2645
+ * So "click a row, land on a settings page" cannot be built from outside the
2646
+ * settings package. The honest fix is to put the screens here, where they are
2647
+ * reachable, and keep the settings pages as a second way in. Both render the same
2648
+ * components, so there is one implementation of each screen and nothing to sync.
2649
+ *
2650
+ * @param {any} props slot props
2651
+ * @returns {any} panel element
2652
+ */
2653
+ function SuitePanel(props) {
2654
+ const t = props.t ?? (key => key)
2655
+ const rev = useStore(revision)
2656
+ // Which screen is open. `null` shows the three-entry list.
2657
+ const [open, setOpen] = useState(null)
2658
+ const [counts, setCounts] = useState(null)
2659
+ const [error, setError] = useState(null)
2660
+
2661
+ // The three numbers that tell the user whether there is anything behind each
2662
+ // row. A panel whose entries all look identical regardless of content is just
2663
+ // a menu; showing counts is what makes it worth opening.
2664
+ //
2665
+ // Two shape facts this relies on, both of which cost a round of debugging when
2666
+ // guessed wrong: the action is `list` (not `listSessions` — the Host method is
2667
+ // named that, the action is not), and it answers with a bare ARRAY.
2668
+ //
2669
+ // `scope: 'all'` is passed deliberately. The panel has no workspace of its own,
2670
+ // and calling `longterm.list` with no scope returned only the GLOBAL store —
2671
+ // 17 rows for a library of 51 — which is why the panel said 17 while the
2672
+ // settings page said something else entirely. 'all' now means every store.
2673
+ useEffect(() => {
2674
+ let alive = true
2675
+ Promise.all([
2676
+ call({ action: 'longterm.list', scope: 'all' }).catch(() => []),
2677
+ call({ action: 'list' }).catch(() => []),
2678
+ // Per-workspace breakdown, so the totals can be explained rather than
2679
+ // merely displayed. The counts above are sums; without this the user
2680
+ // cannot tell which workspace contributes what — the exact question that
2681
+ // made the earlier 17/21/27/51 mismatch look like a bug.
2682
+ call({ action: 'longterm.workspaces' }).catch(() => []),
2683
+ ])
2684
+ .then(([memories, sessions, workspaces]) => {
2685
+ if (!alive) return
2686
+ const items = Array.isArray(sessions) ? sessions : []
2687
+ const stores = Array.isArray(workspaces) ? workspaces : []
2688
+ const memoryRows = Array.isArray(memories) ? memories : []
2689
+ // The global store's size, so the breakdown adds up to the total instead
2690
+ // of leaving the difference unexplained.
2691
+ const globalRows = memoryRows.filter(row => row.scope === 'global').length
2692
+ setCounts({
2693
+ memories: memoryRows.length,
2694
+ memoryActive: memoryRows.filter(row => row.archived !== true).length,
2695
+ memoryArchived: memoryRows.filter(row => row.archived === true).length,
2696
+ memoryGlobal: globalRows,
2697
+ sessions: items.length,
2698
+ snapshots: items.reduce((sum, item) => sum + (item.checkpoints ?? 0), 0),
2699
+ archived: items.filter(item => item.archived === true).length,
2700
+ // Each store's own size, so the total has a visible decomposition.
2701
+ stores: stores.map(store => ({
2702
+ cwd: store.cwd ?? store.key,
2703
+ entries: store.entries ?? 0,
2704
+ })),
2705
+ })
2706
+ })
2707
+ .catch(failure => alive && setError(failure?.message ?? String(failure)))
2708
+ return () => {
2709
+ alive = false
2710
+ }
2711
+ }, [rev])
2712
+
2713
+ // One screen is open: render it, with a way back.
2714
+ if (open) {
2715
+ const screens = {
2716
+ snapshots: SnapshotSection,
2717
+ memory: MemorySection,
2718
+ archived: ArchivedSection,
2719
+ }
2720
+ const Screen = screens[open]
2721
+ return h(
2722
+ 'div',
2723
+ { className: 'dss-panel' },
2724
+ h(
2725
+ 'div',
2726
+ { className: 'dss-panel-nav' },
2727
+ h(
2728
+ 'button',
2729
+ { type: 'button', className: 'dss-btn dss-small dss-ghost', onClick: () => setOpen(null) },
2730
+ `‹ ${t('panel.title')}`,
2731
+ ),
2732
+ ),
2733
+ Screen ? h(Screen, { t }) : null,
2734
+ )
2735
+ }
2736
+
2737
+ /** @param {string} key screen key @param {string} title row title @param {string} hint row hint */
2738
+ const row = (key, title, hint, meta) =>
2739
+ h(
2740
+ 'button',
2741
+ {
2742
+ key,
2743
+ type: 'button',
2744
+ className: 'dss-nav-row',
2745
+ onClick: () => setOpen(key),
2746
+ },
2747
+ h(
2748
+ 'div',
2749
+ { className: 'dss-nav-main' },
2750
+ h('div', { className: 'dss-nav-title' }, title),
2751
+ h('div', { className: 'dss-nav-hint' }, hint),
2752
+ ),
2753
+ meta === undefined ? h(Icons.chevron, null) : h('div', { className: 'dss-nav-meta' }, String(meta)),
2754
+ )
2755
+
2756
+ return h(
2314
2757
  'div',
2315
- { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
2316
- h('div', { className: 'dss-group-title' }, t('settings.memory')),
2758
+ { className: 'dss-panel' },
2759
+ h(
2760
+ 'div',
2761
+ { className: 'dss-section-head' },
2762
+ h('h2', null, t('panel.title')),
2763
+ h('div', { className: 'dss-section-desc' }, t('panel.description')),
2764
+ ),
2765
+ error ? h('div', { className: 'dss-dialog-error' }, error) : null,
2766
+ // A summary strip, because three rows alone left most of the column empty.
2767
+ // The numbers are the same ones the rows carry, so it adds orientation
2768
+ // without inventing a second source of truth.
2769
+ //
2770
+ // Each figure states what it counts. Showing bare numbers is how the earlier
2771
+ // mismatch became a bug report: the user saw 17 here and 21 there, both
2772
+ // labelled "memory", with nothing on screen saying one was the global store
2773
+ // and the other was global plus one workspace.
2317
2774
  h(
2318
2775
  'div',
2319
- { className: 'dss-card' },
2320
- memory.length === 0
2321
- ? h('div', { className: 'dss-empty' }, t('settings.memoryEmpty'))
2322
- : memory.map(entry =>
2776
+ { className: 'dss-stat-strip' },
2777
+ ...[
2778
+ ['panel.statSessions', counts?.sessions, 'panel.statSessionsHint'],
2779
+ ['panel.statSnapshots', counts?.snapshots, 'panel.statSnapshotsHint'],
2780
+ ['panel.statMemories', counts?.memories, 'panel.statMemoriesHint'],
2781
+ ['panel.statArchived', counts?.archived, 'panel.statArchivedHint'],
2782
+ ].map(([label, value, hint]) =>
2783
+ h(
2784
+ 'div',
2785
+ { className: 'dss-stat', key: label, title: t(hint) },
2786
+ h('div', { className: 'dss-stat-value' }, value === undefined ? '—' : String(value)),
2787
+ h('div', { className: 'dss-stat-label' }, t(label)),
2788
+ h('div', { className: 'dss-stat-hint' }, t(hint)),
2789
+ ),
2790
+ ),
2791
+ ),
2792
+ // The decomposition, so the total is checkable rather than a number to trust.
2793
+ counts?.stores?.length
2794
+ ? h(
2795
+ 'div',
2796
+ { className: 'dss-breakdown' },
2797
+ h('div', { className: 'dss-group-title' }, t('panel.breakdownTitle')),
2798
+ h(
2799
+ 'div',
2800
+ { className: 'dss-breakdown-row' },
2801
+ h('span', { className: 'dss-breakdown-key' }, t('panel.breakdownGlobal')),
2802
+ h('span', { className: 'dss-breakdown-value' }, String(counts.memoryGlobal ?? '—')),
2803
+ ),
2804
+ ...counts.stores.map(store =>
2323
2805
  h(
2324
2806
  'div',
2325
- { className: 'dss-row', key: entry.id },
2326
- h('div', { className: 'dss-row-main' }, h('div', { className: 'dss-memory-text' }, entry.text), h('div', { className: 'dss-row-meta' }, h('span', null, new Date(entry.createdAt).toLocaleString()))),
2807
+ { className: 'dss-breakdown-row', key: store.cwd },
2327
2808
  h(
2328
- 'div',
2329
- { className: 'dss-row-actions' },
2330
- h(
2331
- 'button',
2332
- {
2333
- type: 'button',
2334
- className: 'dss-btn dss-small dss-ghost',
2335
- title: t('settings.memoryRemove'),
2336
- onClick: () => run(`mem:${entry.id}`, () => call({ action: 'memory.remove', sessionId: selected, id: entry.id }), t('toast.memoryRemoved')),
2337
- },
2338
- h(Icons.trash, { size: 14 }),
2339
- ),
2809
+ 'span',
2810
+ { className: 'dss-breakdown-key', title: store.cwd },
2811
+ String(store.cwd).split(/[\\/]/).filter(Boolean).pop() ?? store.cwd,
2340
2812
  ),
2813
+ h('span', { className: 'dss-breakdown-value' }, String(store.entries)),
2341
2814
  ),
2342
2815
  ),
2816
+ h('div', { className: 'dss-breakdown-hint' }, t('panel.breakdownHint')),
2817
+ )
2818
+ : null,
2819
+ h(
2820
+ 'div',
2821
+ { className: 'dss-nav' },
2822
+ row('snapshots', t('panel.snapshots'), t('panel.snapshotsHint'), counts?.snapshots),
2823
+ row('memory', t('panel.memory'), t('panel.memoryHint'), counts?.memories),
2824
+ row('archived', t('panel.archived'), t('panel.archivedHint'), counts?.archived),
2343
2825
  ),
2344
2826
  h(
2345
2827
  'div',
2346
- { className: 'dss-toolbar' },
2347
- h('input', {
2348
- className: 'dss-input',
2349
- value: memoryDraft,
2350
- placeholder: t('settings.memoryPlaceholder'),
2351
- onChange: event => setMemoryDraft(event.target.value),
2352
- }),
2828
+ { className: 'dss-panel-footer' },
2829
+ h('span', null, t('panel.sessionsCount', { n: counts?.sessions ?? 0 })),
2830
+ // A way to find the source. dsh-connect-trae puts a GitHub link in the same
2831
+ // place, and it is the one line in a panel like this that answers "who made
2832
+ // this and where do I report a problem".
2353
2833
  h(
2354
- 'button',
2834
+ 'a',
2355
2835
  {
2356
- type: 'button',
2357
- className: 'dss-btn dss-small',
2358
- disabled: !memoryDraft.trim() || !selected,
2359
- onClick: () =>
2360
- run('memory:add', async () => {
2361
- await call({ action: 'memory.add', sessionId: selected, text: memoryDraft })
2362
- setMemoryDraft('')
2363
- }, t('toast.memoryAdded')),
2836
+ className: 'dss-panel-link',
2837
+ href: 'https://github.com/heixiua/dsh-session-suite',
2838
+ target: '_blank',
2839
+ rel: 'noreferrer noopener',
2364
2840
  },
2365
- t('settings.memorySave'),
2841
+ t('panel.repoLink'),
2366
2842
  ),
2367
2843
  ),
2368
2844
  )
2845
+ }
2369
2846
 
2370
- return h(
2847
+ // --------------------------------------------------- archived sessions
2848
+
2849
+ /**
2850
+ * Archived sessions, with a way back.
2851
+ *
2852
+ * Archiving existed as a Host command but had no surface here, so a session
2853
+ * could be archived and then be unreachable — the sidebar hides it and the only
2854
+ * way back was the Host's own menu, which is disabled in the context where the
2855
+ * archive happened. This page is the missing half: it lists what the sidebar is
2856
+ * hiding and offers restore.
2857
+ *
2858
+ * Restore, not delete, is the primary action. Archived rows are still complete
2859
+ * sessions; the common mistake is to treat "hidden" as "gone" and destroy data
2860
+ * to tidy a list.
2861
+ *
2862
+ * @param {any} props slot props
2863
+ * @returns {any} section element
2864
+ */
2865
+ function ArchivedSection(props) {
2866
+ const t = props.t ?? (key => key)
2867
+ const rev = useStore(revision)
2868
+ const [rows, setRows] = useState(null)
2869
+ const [error, setError] = useState(null)
2870
+ const [busy, setBusy] = useState(null)
2871
+
2872
+ useEffect(() => {
2873
+ let alive = true
2874
+ // `list` answers with a bare array; see the note in SuitePanel.
2875
+ call({ action: 'list' })
2876
+ .then(result => {
2877
+ if (!alive) return
2878
+ const items = Array.isArray(result) ? result : []
2879
+ setRows(items.filter(item => item.archived === true))
2880
+ })
2881
+ .catch(failure => alive && setError(failure?.message ?? String(failure)))
2882
+ return () => {
2883
+ alive = false
2884
+ }
2885
+ }, [rev])
2886
+
2887
+ /** @param {string} sessionId session to restore */
2888
+ const restore = async sessionId => {
2889
+ setBusy(sessionId)
2890
+ setError(null)
2891
+ try {
2892
+ await call({ action: 'restoreSession', sessionId })
2893
+ notify(t('archived.restored'))
2894
+ bump()
2895
+ } catch (failure) {
2896
+ setError(failure?.message ?? t('archived.restoreFailed'))
2897
+ } finally {
2898
+ setBusy(null)
2899
+ }
2900
+ }
2901
+
2902
+ const head = h(
2371
2903
  'div',
2372
- { className: sectionClass },
2373
- head,
2374
- picker,
2375
- statsLine,
2376
- checkpointList,
2377
- undoList,
2378
- memoryBlock,
2904
+ { className: 'dss-section-head' },
2905
+ h('h2', null, t('archived.title')),
2906
+ h('div', { className: 'dss-section-desc' }, t('archived.description')),
2379
2907
  )
2380
- }
2381
2908
 
2382
- // ------------------------------------------------------- long-term memory
2909
+ if (error) return h('div', { className: 'dss-section' }, head, h('div', { className: 'dss-dialog-error' }, error))
2910
+ if (!rows) return h('div', { className: 'dss-section' }, head, h('div', { className: 'dss-empty' }, t('settings.loading')))
2383
2911
 
2384
- function useWorkspacePath() {
2385
- return runtime.resolveCwd?.() ?? runtime.cwd
2386
- }
2912
+ const body =
2913
+ rows.length === 0
2914
+ ? h('div', { className: 'dss-empty' }, t('archived.empty'))
2915
+ : h(
2916
+ 'div',
2917
+ { className: 'dss-card' },
2918
+ ...rows.map(entry =>
2919
+ h(
2920
+ 'div',
2921
+ { className: 'dss-row', key: entry.sessionId },
2922
+ h(
2923
+ 'div',
2924
+ { className: 'dss-row-main' },
2925
+ h('div', { className: 'dss-memory-text' }, entry.title || entry.sessionId),
2926
+ h(
2927
+ 'div',
2928
+ { className: 'dss-row-meta' },
2929
+ h('span', { className: 'dss-badge dss-temp' }, t('settings.archived')),
2930
+ typeof entry.checkpoints === 'number'
2931
+ ? h('span', null, t('settings.checkpointCount', { n: entry.checkpoints }))
2932
+ : null,
2933
+ ),
2934
+ ),
2935
+ h(
2936
+ 'div',
2937
+ { className: 'dss-row-actions' },
2938
+ h(
2939
+ 'button',
2940
+ {
2941
+ type: 'button',
2942
+ className: 'dss-btn dss-small',
2943
+ disabled: busy === entry.sessionId,
2944
+ onClick: () => restore(entry.sessionId),
2945
+ },
2946
+ busy === entry.sessionId ? t('settings.working') : t('archived.restore'),
2947
+ ),
2948
+ ),
2949
+ ),
2950
+ ),
2951
+ )
2387
2952
 
2388
- /** Last path segment, for a compact workspace label. */
2389
- function basename(value) {
2390
- const text = String(value ?? '')
2391
- const cut = Math.max(text.lastIndexOf('/'), text.lastIndexOf('\\'))
2392
- return cut >= 0 ? text.slice(cut + 1) || text : text
2953
+ return h('div', { className: 'dss-section' }, head, body)
2393
2954
  }
2394
2955
 
2395
2956
  function MemorySection(props) {
@@ -2421,7 +2982,39 @@ window.__ModuleLoader__.load({
2421
2982
  const [tagFilter, setTagFilter] = useState('')
2422
2983
  const [sinceFilter, setSinceFilter] = useState('')
2423
2984
 
2985
+ // Which models can be called, loaded from the Host rather than from config.
2986
+ //
2987
+ // The list used to come from `settings.memoryModels`, which is a patch value —
2988
+ // and this profile has no patch entry, so the picker offered only "off" and
2989
+ // "use current" while the user had several working providers in
2990
+ // Settings → 模型. The Host knows what it can actually call; ask it.
2991
+ const [models, setModels] = useState(null)
2992
+ useEffect(() => {
2993
+ let alive = true
2994
+ call({ action: 'longterm.models' })
2995
+ .then(list => alive && setModels(Array.isArray(list) ? list : []))
2996
+ .catch(() => alive && setModels([]))
2997
+ return () => {
2998
+ alive = false
2999
+ }
3000
+ }, [rev])
3001
+
2424
3002
  const detected = useWorkspacePath()
3003
+
3004
+ // Turn a stored workspace key into a readable name.
3005
+ //
3006
+ // The badge used to say 「本工作区」 for every workspace-scoped row, so a list
3007
+ // filtered to 全部 showed a dozen rows all claiming to be "this workspace" —
3008
+ // including rows from other workspaces. The key is a one-way hash, so the name
3009
+ // has to come from the registry, which is exactly what `longterm.workspaces`
3010
+ // returns.
3011
+ const workspaceLabel = key => {
3012
+ if (!key) return t('memory.workspace')
3013
+ const match = known.find(item => item.key === key)
3014
+ if (!match) return t('memory.workspace')
3015
+ const cwd = String(match.cwd ?? '')
3016
+ return cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd ?? t('memory.workspace')
3017
+ }
2425
3018
  const cwd = picked !== undefined ? picked : detected
2426
3019
 
2427
3020
  useEffect(() => {
@@ -2603,7 +3196,7 @@ window.__ModuleLoader__.load({
2603
3196
  'div',
2604
3197
  { className: 'dss-row-main' },
2605
3198
  h('div', { className: 'dss-memory-text' }, item.text),
2606
- h('div', { className: 'dss-row-meta' }, h('span', { className: 'dss-badge' }, item.scope === 'global' ? t('memory.global') : t('memory.workspace'))),
3199
+ h('div', { className: 'dss-row-meta' }, h('span', { className: 'dss-badge' }, item.scope === 'global' ? t('memory.global') : workspaceLabel(item.workspaceKey))),
2607
3200
  ),
2608
3201
  h(
2609
3202
  'div',
@@ -2632,40 +3225,65 @@ window.__ModuleLoader__.load({
2632
3225
  ),
2633
3226
  ),
2634
3227
  )
2635
- : extraction
2636
- ? h(
3228
+ : h(
3229
+ // Always render this block, even when extraction has never run.
3230
+ //
3231
+ // It used to render only when a trace existed, so a freshly started
3232
+ // Host showed NOTHING here — indistinguishable from a broken feature,
3233
+ // and exactly the question the user then asked. An empty state has to
3234
+ // say why it is empty.
3235
+ 'div',
3236
+ { className: 'dss-mem-pending' },
3237
+ h('div', { className: 'dss-group-title' }, t('memory.pendingTitle')),
3238
+ h(
2637
3239
  'div',
2638
- { className: 'dss-mem-pending' },
2639
- h('div', { className: 'dss-group-title' }, t('memory.pendingTitle')),
2640
- h(
2641
- 'div',
2642
- { className: 'dss-mem-pending-hint' },
2643
- t('memory.extractionIdle', {
2644
- reason: extraction.reason || t('memory.extractionUnknown'),
2645
- }),
2646
- ),
2647
- extraction.candidates?.length
2648
- ? h(
2649
- 'div',
2650
- { className: 'dss-row-meta' },
2651
- h('span', null, t('memory.extractionTried', { n: extraction.candidates.length })),
2652
- extraction.queued !== undefined
2653
- ? h('span', null, t('memory.extractionQueued', { n: extraction.queued }))
2654
- : null,
2655
- extraction.rejected !== undefined
2656
- ? h('span', null, t('memory.extractionRejected', { n: extraction.rejected }))
2657
- : null,
2658
- )
2659
- : null,
2660
- )
2661
- : null
3240
+ { className: 'dss-mem-pending-hint' },
3241
+ extraction
3242
+ ? t('memory.extractionIdle', {
3243
+ reason: extraction.reason || t('memory.extractionUnknown'),
3244
+ })
3245
+ : t('memory.extractionNotYet'),
3246
+ ),
3247
+ extraction?.candidates?.length
3248
+ ? h(
3249
+ 'div',
3250
+ { className: 'dss-row-meta' },
3251
+ h('span', null, t('memory.extractionTried', { n: extraction.candidates.length })),
3252
+ extraction.queued !== undefined
3253
+ ? h('span', null, t('memory.extractionQueued', { n: extraction.queued }))
3254
+ : null,
3255
+ extraction.rejected !== undefined
3256
+ ? h('span', null, t('memory.extractionRejected', { n: extraction.rejected }))
3257
+ : null,
3258
+ )
3259
+ : null,
3260
+ // Raw counts of what the Host actually delivered, so "nothing waiting"
3261
+ // can be told apart from "the hook never fired". Without this the two
3262
+ // states look identical on screen.
3263
+ extraction
3264
+ ? h(
3265
+ 'div',
3266
+ { className: 'dss-row-meta' },
3267
+ h('span', null, t('memory.extractionEvents', { n: extraction.eventsSeen ?? 0 })),
3268
+ h('span', null, t('memory.extractionTurns', { n: extraction.turnEndsSeen ?? 0 })),
3269
+ )
3270
+ : null,
3271
+ )
2662
3272
 
3273
+ // The list gets its own scroll box.
3274
+ //
3275
+ // Without one, the whole page scrolled: fifty memories pushed the maintenance
3276
+ // and model controls far below the fold, so changing a setting meant scrolling
3277
+ // past the entire library first. A bounded box keeps the controls in place and
3278
+ // makes the list the only thing that moves. The height is a deliberate starting
3279
+ // point — roughly eight rows — chosen to leave the settings visible on a
3280
+ // laptop screen; it is a single number, easy to retune.
2663
3281
  const listBlock =
2664
3282
  visible.length === 0
2665
3283
  ? h('div', { className: 'dss-empty' }, t('memory.empty'))
2666
3284
  : h(
2667
3285
  'div',
2668
- { className: 'dss-card' },
3286
+ { className: 'dss-card dss-scroll-box' },
2669
3287
  ...visible.map(entry =>
2670
3288
  h(
2671
3289
  'div',
@@ -2677,7 +3295,7 @@ window.__ModuleLoader__.load({
2677
3295
  h(
2678
3296
  'div',
2679
3297
  { className: 'dss-row-meta' },
2680
- h('span', { className: 'dss-badge' + (entry.scope === 'global' ? ' dss-locked' : '') }, entry.scope === 'global' ? t('memory.global') : t('memory.workspace')),
3298
+ h('span', { className: 'dss-badge' + (entry.scope === 'global' ? ' dss-locked' : '') }, entry.scope === 'global' ? t('memory.global') : workspaceLabel(entry.workspaceKey)),
2681
3299
  entry.kind === 'rule' ? h('span', { className: 'dss-badge dss-temp' }, t('memory.rule')) : null,
2682
3300
  // An episode is a dated event, not a truth. Labelling it
2683
3301
  // separately is what lets the user tell "this happened once"
@@ -2782,6 +3400,44 @@ window.__ModuleLoader__.load({
2782
3400
  }),
2783
3401
  h('span', { className: 'dss-curate-title' }, t('memory.implicit')),
2784
3402
  ),
3403
+ // Which model does the background extraction call?
3404
+ //
3405
+ // This has to be an explicit choice. An earlier build silently fell back to
3406
+ // the Host's default model whenever no chain was configured, which spends the
3407
+ // user's tokens once per turn on a model they never picked for this job. A
3408
+ // background cost has to be visible and opt-in, so the choice is a control
3409
+ // with an explicit "off" that costs nothing.
3410
+ h(
3411
+ 'div',
3412
+ { className: 'dss-curate-field' },
3413
+ h('label', { className: 'dss-curate-label' }, t('memory.implicitModel')),
3414
+ h(
3415
+ 'select',
3416
+ {
3417
+ className: 'dss-select',
3418
+ value: settings?.implicitModel ?? 'off',
3419
+ disabled: busy === 'implicitModel',
3420
+ onChange: event =>
3421
+ run('implicitModel', () =>
3422
+ call({ action: 'longterm.settings', patch: { implicitModel: event.target.value } }),
3423
+ ),
3424
+ },
3425
+ h('option', { value: 'off' }, t('memory.implicitModelOff')),
3426
+ h('option', { value: 'current' }, t('memory.implicitModelCurrent')),
3427
+ // Real models from the Host, grouped by provider so a long list stays
3428
+ // readable. `models` is null while loading and [] when the Host reports
3429
+ // none — both render no group, which is honest: the two above are the
3430
+ // only choices that exist without a callable model.
3431
+ ...(Array.isArray(models) ? models : []).map(entry =>
3432
+ h(
3433
+ 'option',
3434
+ { value: `${entry.provider}/${entry.model}`, key: `${entry.provider}/${entry.model}` },
3435
+ entry.label ?? `${entry.provider}/${entry.model}`,
3436
+ ),
3437
+ ),
3438
+ ),
3439
+ h('div', { className: 'dss-curate-hint' }, t('memory.implicitModelHint')),
3440
+ ),
2785
3441
  h(
2786
3442
  'button',
2787
3443
  {
@@ -3034,6 +3690,29 @@ window.__ModuleLoader__.load({
3034
3690
  )
3035
3691
  }
3036
3692
 
3693
+ // ---------------------------------------------------- sidebar entry
3694
+ //
3695
+ // A first-class sidebar entry, matching how dsh-connect-trae,
3696
+ // dsh-connect-workbuddy and dsh-context present themselves: an icon in
3697
+ // `sidebar.panellist` plus a panel under the same key in `main`. Both are
3698
+ // additive (`replaceRisk: none` on the list, and the key is one only this
3699
+ // plugin claims), so nothing shipped is shadowed.
3700
+ //
3701
+ // Why a panel at all: the features already existed but were reachable only
3702
+ // through Settings → one of two sections, three steps from anywhere. The
3703
+ // panel is pure navigation — it does not reimplement the sections, it opens
3704
+ // them, so there is one implementation of every screen.
3705
+ ctx.slots.inject('sidebar.panellist', () =>
3706
+ ctx.slots.register(
3707
+ { name: 'sidebar.panellist', id: 'session-suite', order: 25, locale: NS },
3708
+ withT(SuiteIcon),
3709
+ ),
3710
+ )
3711
+
3712
+ ctx.slots.inject('main', () =>
3713
+ ctx.slots.register({ name: 'main', key: 'session-suite' }, withT(SuitePanel)),
3714
+ )
3715
+
3037
3716
  ctx.slots.inject('conversation.chat.assistant-actions', () =>
3038
3717
  ctx.slots.register(
3039
3718
  { name: 'conversation.chat.assistant-actions', id: 'session-suite-rewind', order: 20, locale: NS },
@@ -3052,31 +3731,15 @@ window.__ModuleLoader__.load({
3052
3731
  ctx.slots.register({ name: 'shell.overlay', id: 'session-suite-overlay', order: 40 }, SuiteOverlay),
3053
3732
  )
3054
3733
 
3055
- ctx.slots.inject('settings.section', () =>
3056
- ctx.slots.register(
3057
- {
3058
- name: 'settings.section',
3059
- id: 'session-snapshots',
3060
- order: 500,
3061
- locale: NS,
3062
- label: () => t('settings.title'),
3063
- },
3064
- withT(SnapshotSection),
3065
- ),
3066
- )
3067
-
3068
- ctx.slots.inject('settings.section', () =>
3069
- ctx.slots.register(
3070
- {
3071
- name: 'settings.section',
3072
- id: 'long-term-memory',
3073
- order: 520,
3074
- locale: NS,
3075
- label: () => t('memory.title'),
3076
- },
3077
- withT(MemorySection),
3078
- ),
3079
- )
3734
+ // The three settings sections are gone on purpose.
3735
+ //
3736
+ // They were the only way in before the sidebar panel existed, so the same
3737
+ // three screens appeared twice: once under Settings and once behind the
3738
+ // sidebar icon. Two entry points for one screen is not convenience, it is
3739
+ // two places to keep consistent and two places for the user to wonder about.
3740
+ // The panel is the primary surface now; these registrations were removed
3741
+ // rather than hidden behind a flag, because a dead section in Settings is
3742
+ // worse than an absent one.
3080
3743
  }
3081
3744
  },
3082
3745
  }