@heixui/dsh-session-suite 0.3.4 → 0.4.1

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…',
@@ -178,6 +177,7 @@ window.__ModuleLoader__.load({
178
177
  'memory.workspacePicker': 'Workspace',
179
178
  'memory.workspaceNone': 'No workspace detected',
180
179
  'memory.rule': 'Rule',
180
+ 'memory.episode': 'Episode',
181
181
  'memory.summary': 'Summary',
182
182
  'memory.frozen': 'Frozen',
183
183
  'memory.archived': 'Archived',
@@ -210,6 +210,44 @@ window.__ModuleLoader__.load({
210
210
  'memory.remove': 'Delete',
211
211
  'memory.pendingTitle': 'Suggested memories',
212
212
  'memory.pendingHint': 'Extracted from the last turns. Nothing is stored until you accept it.',
213
+ 'memory.extractionIdle': 'Nothing is waiting. Last check: {reason}',
214
+ 'memory.extractionNotYet':
215
+ '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.',
216
+ 'memory.extractionUnknown': 'no attempt recorded since this Host started',
217
+ 'panel.title': 'Session suite',
218
+ 'panel.description': 'Snapshots, long-term memory, and archived sessions. Each row opens its own page.',
219
+ 'panel.snapshots': 'Session snapshots',
220
+ 'panel.snapshotsHint': 'Workspace checkpoints taken when each turn finishes',
221
+ 'panel.memory': 'Long-term memory',
222
+ 'panel.memoryHint': 'Facts, rules, preferences, and past events kept across sessions',
223
+ 'panel.archived': 'Archived sessions',
224
+ 'panel.archivedHint': 'Sessions hidden from the sidebar; restore them here',
225
+ 'panel.sessionsCount': '{n} session(s) with snapshots',
226
+ 'panel.statSessions': 'Sessions',
227
+ 'panel.statSnapshots': 'Snapshots',
228
+ 'panel.statMemories': 'Memories',
229
+ 'panel.statArchived': 'Archived',
230
+ 'panel.statSessionsHint': 'Every workspace, subagents excluded',
231
+ 'panel.statSnapshotsHint': 'Automatic checkpoints across all sessions',
232
+ 'panel.statMemoriesHint': 'Global plus every workspace, archived included',
233
+ 'panel.statArchivedHint': 'Hidden from the sidebar, restorable',
234
+ 'panel.breakdownTitle': 'Where the memories live',
235
+ 'panel.breakdownGlobal': 'Global (shared by every workspace)',
236
+ 'panel.breakdownHint': 'Each workspace keeps its own store; the total above is the sum of these plus the global one.',
237
+ 'panel.repoLink': 'Source on GitHub',
238
+ 'panel.noSettingsSeam': 'This Host build does not expose the settings navigation seam, so this row cannot open its page.',
239
+ 'archived.title': 'Archived sessions',
240
+ 'archived.description': 'Archived sessions are hidden from the sidebar. Restoring one puts it back; nothing is deleted.',
241
+ 'archived.empty': 'No archived sessions.',
242
+ 'archived.restore': 'Restore',
243
+ 'archived.restored': 'Restored',
244
+ 'archived.restoreFailed': 'Could not restore this session',
245
+ 'settings.archived': 'Archived',
246
+ 'settings.checkpointCount': '{n} snapshot(s)',
247
+ 'settings.working': 'Working…',
248
+ 'memory.extractionTried': 'tried {n} memory model(s)',
249
+ 'memory.extractionQueued': '{n} queued',
250
+ 'memory.extractionRejected': '{n} rejected as unusable',
213
251
  'memory.accept': 'Keep',
214
252
  'memory.reject': 'Discard',
215
253
  'memory.accepted': 'Kept',
@@ -357,6 +395,7 @@ window.__ModuleLoader__.load({
357
395
  'memory.workspacePicker': '工作区',
358
396
  'memory.workspaceNone': '未检测到工作区',
359
397
  'memory.rule': '规则',
398
+ 'memory.episode': '经历',
360
399
  'memory.summary': '摘要',
361
400
  'memory.frozen': '已冻结',
362
401
  'memory.archived': '已归档',
@@ -389,6 +428,44 @@ window.__ModuleLoader__.load({
389
428
  'memory.remove': '删除',
390
429
  'memory.pendingTitle': '待确认的记忆',
391
430
  'memory.pendingHint': '从最近几轮里识别出来的,确认前不会入库。',
431
+ 'memory.extractionIdle': '当前没有待确认条目。上次检查结果:{reason}',
432
+ 'memory.extractionNotYet':
433
+ '暂时还没有待确认条目。提取在每轮对话结束时运行,而本次启动后还没完成过任何一轮——聊一两句再回来看。',
434
+ 'memory.extractionUnknown': '本次运行还没有执行过提取',
435
+ 'panel.title': '会话套件',
436
+ 'panel.description': '会话快照、长期记忆、归档会话。每行点开就是对应的页面。',
437
+ 'panel.snapshots': '会话快照',
438
+ 'panel.snapshotsHint': '每轮结束时自动记录的工作区状态',
439
+ 'panel.memory': '长期记忆',
440
+ 'panel.memoryHint': '跨会话保留的事实、规则、偏好与经历',
441
+ 'panel.archived': '归档会话',
442
+ 'panel.archivedHint': '已从侧边栏隐藏的会话,可以在这里恢复',
443
+ 'panel.sessionsCount': '{n} 个会话有快照',
444
+ 'panel.statSessions': '会话',
445
+ 'panel.statSnapshots': '快照',
446
+ 'panel.statMemories': '记忆',
447
+ 'panel.statArchived': '已归档',
448
+ 'panel.statSessionsHint': '所有工作区,不含子代理会话',
449
+ 'panel.statSnapshotsHint': '所有会话的自动快照总数',
450
+ 'panel.statMemoriesHint': '全局 + 各工作区,含已归档',
451
+ 'panel.statArchivedHint': '从侧边栏隐藏,可恢复',
452
+ 'panel.breakdownTitle': '记忆存在哪',
453
+ 'panel.breakdownGlobal': '全局(所有工作区共享)',
454
+ 'panel.breakdownHint': '每个工作区有独立的库;上面的总数 = 这些之和 + 全局。',
455
+ 'panel.repoLink': 'GitHub 源码',
456
+ 'panel.noSettingsSeam': '这个 Host 版本没有暴露设置导航接口,无法打开对应页面。',
457
+ 'archived.title': '归档会话',
458
+ 'archived.description': '归档的会话从侧边栏隐藏。恢复后会重新出现,不会删除任何东西。',
459
+ 'archived.empty': '没有归档的会话。',
460
+ 'archived.restore': '恢复',
461
+ 'archived.restored': '已恢复',
462
+ 'archived.restoreFailed': '恢复失败',
463
+ 'settings.archived': '已归档',
464
+ 'settings.checkpointCount': '{n} 张快照',
465
+ 'settings.working': '处理中…',
466
+ 'memory.extractionTried': '试过 {n} 个记忆模型',
467
+ 'memory.extractionQueued': '入队 {n} 条',
468
+ 'memory.extractionRejected': '剔除 {n} 条',
392
469
  'memory.accept': '保留',
393
470
  'memory.reject': '丢弃',
394
471
  'memory.accepted': '已保留',
@@ -556,6 +633,9 @@ window.__ModuleLoader__.load({
556
633
  h('path', { key: 'd', d: 'M10.2 10.2 4.8 2.6' }),
557
634
  ]),
558
635
  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' })]),
636
+ // A right-pointing chevron: these rows navigate somewhere, so the row's
637
+ // affordance is "go", not "expand".
638
+ chevron: () => icon([h('path', { key: 'a', d: 'M6.2 3.6 10.6 8l-4.4 4.4' })]),
559
639
  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' })]),
560
640
  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' })]),
561
641
  close: () => icon([h('path', { key: 'a', d: 'M4 4l8 8' }), h('path', { key: 'b', d: 'M12 4l-8 8' })]),
@@ -748,6 +828,118 @@ window.__ModuleLoader__.load({
748
828
  .dss-toast.dss-error { background: var(--dsw-alias-state-error-primary, #d93025); }
749
829
 
750
830
  .dss-section { display: flex; flex-direction: column; gap: 12px; max-width: 720px; }
831
+ /* A screen opened from the sidebar panel inherits the panel's scroll container, so it
832
+ must not impose its own height. The settings dialog still renders these sections
833
+ directly and relies on the dialog's own scrolling. */
834
+ /* The sidebar panel is a main-column surface. It must scroll: the memory list is
835
+ dozens of rows, and without an explicit overflow the page clipped at the viewport
836
+ with no way to reach the rest — reported as "no scroll wheel, there are already
837
+ a dozen entries". The min-height 0 is required for the child flex column to shrink
838
+ below its content size, otherwise overflow never triggers. */
839
+ .dss-panel {
840
+ display: flex; flex-direction: column; gap: 14px;
841
+ padding: 24px 28px; max-width: 760px; margin: 0 auto;
842
+ height: 100%; min-height: 0;
843
+ overflow-y: auto; overscroll-behavior: contain;
844
+ }
845
+ .dss-panel h2 { margin: 0; font-size: 17px; font-weight: 600; color: var(--dsw-alias-label-primary, #1f1f1f); }
846
+ .dss-nav { display: flex; flex-direction: column; gap: 10px; }
847
+ /* Rows follow the shipped card idiom: the same border and background tokens
848
+ dsh-connect-trae uses (--dsw-alias-border-l2 / --dsw-alias-bg-l2), so the panel
849
+ looks like part of the app rather than a bolt-on. A left accent bar appears on
850
+ hover and focus instead of a colour swap, which keeps the text readable in both
851
+ themes and gives keyboard users the same affordance as mouse users. */
852
+ .dss-nav-row {
853
+ position: relative;
854
+ display: flex; align-items: center; gap: 14px; width: 100%;
855
+ padding: 14px 16px; text-align: left; cursor: pointer;
856
+ font: inherit; color: inherit;
857
+ background: var(--dsw-alias-bg-l2, #fff);
858
+ border: 1px solid var(--dsw-alias-border-l2, #e3e4e6);
859
+ border-radius: 12px;
860
+ transition: border-color .14s ease, background .14s ease, transform .14s ease;
861
+ }
862
+ .dss-nav-row::before {
863
+ content: ''; position: absolute; left: 0; top: 12px; bottom: 12px; width: 3px;
864
+ border-radius: 0 3px 3px 0;
865
+ background: var(--dsw-alias-brand, #4d6bfe);
866
+ opacity: 0; transition: opacity .14s ease;
867
+ }
868
+ .dss-nav-row:hover, .dss-nav-row:focus-visible {
869
+ border-color: var(--dsw-alias-brand, #4d6bfe);
870
+ background: var(--dsw-alias-bg-l3, #f7f8fa);
871
+ outline: none;
872
+ }
873
+ .dss-nav-row:hover::before, .dss-nav-row:focus-visible::before { opacity: 1; }
874
+ .dss-nav-row:active { transform: translateY(1px); }
875
+ .dss-nav-row > svg { flex: none; color: var(--dsw-alias-label-tertiary, #9a9a9a); }
876
+ .dss-nav-main { display: flex; flex-direction: column; gap: 3px; flex: 1; min-width: 0; }
877
+ .dss-nav-title { font-size: 13.5px; font-weight: 600; color: var(--dsw-alias-label-primary, #1f1f1f); }
878
+ .dss-nav-hint { font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-secondary, #6b6b6b); }
879
+ .dss-nav-meta {
880
+ flex: none; min-width: 28px; text-align: center;
881
+ font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums;
882
+ color: var(--dsw-alias-label-secondary, #6b6b6b);
883
+ background: var(--dsw-alias-bg-l3, #f2f3f5);
884
+ border: 1px solid var(--dsw-alias-border-l2, #e3e4e6);
885
+ border-radius: 999px; padding: 2px 9px;
886
+ }
887
+ .dss-panel-footer {
888
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
889
+ font-size: 12px; color: var(--dsw-alias-label-tertiary, #9a9a9a);
890
+ padding-top: 8px; border-top: 1px solid var(--dsw-alias-border-l3, #f0f0f0);
891
+ }
892
+ .dss-panel-link {
893
+ color: var(--dsw-alias-brand, #4d6bfe);
894
+ text-decoration: none; font-size: 12px;
895
+ }
896
+ .dss-panel-link:hover, .dss-panel-link:focus-visible { text-decoration: underline; }
897
+ /* A four-up summary strip. It fills the column with information rather than
898
+ decoration, and the numbers match the badges on the rows below. */
899
+ .dss-stat-strip {
900
+ display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px;
901
+ }
902
+ .dss-stat {
903
+ display: flex; flex-direction: column; gap: 3px;
904
+ padding: 14px 16px;
905
+ background: var(--dsw-alias-bg-l2, #fff);
906
+ border: 1px solid var(--dsw-alias-border-l2, #e3e4e6);
907
+ border-radius: 12px;
908
+ }
909
+ .dss-stat-value {
910
+ font-size: 22px; font-weight: 650; line-height: 1.15;
911
+ font-variant-numeric: tabular-nums;
912
+ color: var(--dsw-alias-label-primary, #1f1f1f);
913
+ }
914
+ .dss-stat-label {
915
+ font-size: 11.5px; color: var(--dsw-alias-label-secondary, #6b6b6b);
916
+ }
917
+ /* The caption under each figure. Small and tertiary: it explains the number without
918
+ competing with it. */
919
+ .dss-stat-hint {
920
+ font-size: 10.5px; line-height: 1.4;
921
+ color: var(--dsw-alias-label-tertiary, #9a9a9a);
922
+ }
923
+ .dss-breakdown {
924
+ display: flex; flex-direction: column; gap: 6px;
925
+ padding: 14px 16px;
926
+ background: var(--dsw-alias-bg-l3, #f7f8fa);
927
+ border: 1px solid var(--dsw-alias-border-l2, #e3e4e6);
928
+ border-radius: 12px;
929
+ }
930
+ .dss-breakdown-row { display: flex; align-items: baseline; gap: 10px; font-size: 12.5px; }
931
+ .dss-breakdown-key {
932
+ flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
933
+ color: var(--dsw-alias-label-primary, #1f1f1f);
934
+ }
935
+ .dss-breakdown-value {
936
+ flex: none; font-variant-numeric: tabular-nums; font-weight: 600;
937
+ color: var(--dsw-alias-label-secondary, #6b6b6b);
938
+ }
939
+ .dss-breakdown-hint { font-size: 11px; line-height: 1.5; color: var(--dsw-alias-label-tertiary, #9a9a9a); }
940
+ /* The way back from a screen. Left-aligned and small: it is an escape hatch, not
941
+ an action competing with the page's own controls. */
942
+ .dss-panel-nav { display: flex; align-items: center; margin-bottom: -2px; }
751
943
  .dss-section.dss-embedded { max-width: none; gap: 10px; }
752
944
  .dss-section h2 { margin: 0; font-size: 15px; font-weight: 600; color: var(--dsw-alias-label-primary, #1f1f1f); }
753
945
  .dss-section-head { display: flex; flex-direction: column; gap: 4px; }
@@ -2380,6 +2572,369 @@ window.__ModuleLoader__.load({
2380
2572
  return cut >= 0 ? text.slice(cut + 1) || text : text
2381
2573
  }
2382
2574
 
2575
+ // --------------------------------------------------------- sidebar panel
2576
+
2577
+ /**
2578
+ * The sidebar icon.
2579
+ *
2580
+ * The owner supplies `size` and `active`, so the glyph has to inherit both
2581
+ * rather than carry its own colour — a tinted icon reads as branding and
2582
+ * clashes with the selected state the shell already draws around it.
2583
+ *
2584
+ * @param {any} props owner props
2585
+ * @returns {any} icon element
2586
+ */
2587
+ function SuiteIcon(props) {
2588
+ const size = typeof props?.size === 'number' ? props.size : 18
2589
+ return h(
2590
+ 'svg',
2591
+ {
2592
+ width: size,
2593
+ height: size,
2594
+ viewBox: '0 0 16 16',
2595
+ fill: 'none',
2596
+ stroke: 'currentColor',
2597
+ strokeWidth: 1.4,
2598
+ strokeLinecap: 'round',
2599
+ strokeLinejoin: 'round',
2600
+ 'aria-hidden': 'true',
2601
+ },
2602
+ // Two stacked frames: this bundle is about sessions kept as snapshots, so
2603
+ // the mark is layers rather than the clock-like arc a "history" icon would
2604
+ // use (and which already means rewind in the action row).
2605
+ h('rect', { key: 'a', x: 2.6, y: 4.4, width: 8.4, height: 8.2, rx: 1.6 }),
2606
+ 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' }),
2607
+ )
2608
+ }
2609
+
2610
+ /**
2611
+ * The panel shown when the sidebar icon is selected.
2612
+ *
2613
+ * It renders the three screens IN PLACE rather than navigating to Settings.
2614
+ *
2615
+ * That is not the first design. The first version was a navigation menu whose
2616
+ * rows called the settings shell's `openSection(id)`, copied from a comment in
2617
+ * dsh-connect-trae. It did nothing, and reading the shipped bundles explains why:
2618
+ *
2619
+ * • `createSettingsShellStore()` is created inside
2620
+ * `dsh-client-ui-settings-general` and never published — no `reflect.provide`,
2621
+ * no service name — so no other client plugin can reach `openSection`.
2622
+ * • `pluginNavigation` (the one real navigation service) offers only
2623
+ * `openList()` and `openBundle(name)`, and `openBundle` lands on the bundle
2624
+ * page (`view: { kind: 'package' }`), which renders `plugins.bundle.config`
2625
+ * and bundle rows — it does not render `settings.section` pages at all.
2626
+ *
2627
+ * So "click a row, land on a settings page" cannot be built from outside the
2628
+ * settings package. The honest fix is to put the screens here, where they are
2629
+ * reachable, and keep the settings pages as a second way in. Both render the same
2630
+ * components, so there is one implementation of each screen and nothing to sync.
2631
+ *
2632
+ * @param {any} props slot props
2633
+ * @returns {any} panel element
2634
+ */
2635
+ function SuitePanel(props) {
2636
+ const t = props.t ?? (key => key)
2637
+ const rev = useStore(revision)
2638
+ // Which screen is open. `null` shows the three-entry list.
2639
+ const [open, setOpen] = useState(null)
2640
+ const [counts, setCounts] = useState(null)
2641
+ const [error, setError] = useState(null)
2642
+
2643
+ // The three numbers that tell the user whether there is anything behind each
2644
+ // row. A panel whose entries all look identical regardless of content is just
2645
+ // a menu; showing counts is what makes it worth opening.
2646
+ //
2647
+ // Two shape facts this relies on, both of which cost a round of debugging when
2648
+ // guessed wrong: the action is `list` (not `listSessions` — the Host method is
2649
+ // named that, the action is not), and it answers with a bare ARRAY.
2650
+ //
2651
+ // `scope: 'all'` is passed deliberately. The panel has no workspace of its own,
2652
+ // and calling `longterm.list` with no scope returned only the GLOBAL store —
2653
+ // 17 rows for a library of 51 — which is why the panel said 17 while the
2654
+ // settings page said something else entirely. 'all' now means every store.
2655
+ useEffect(() => {
2656
+ let alive = true
2657
+ Promise.all([
2658
+ call({ action: 'longterm.list', scope: 'all' }).catch(() => []),
2659
+ call({ action: 'list' }).catch(() => []),
2660
+ // Per-workspace breakdown, so the totals can be explained rather than
2661
+ // merely displayed. The counts above are sums; without this the user
2662
+ // cannot tell which workspace contributes what — the exact question that
2663
+ // made the earlier 17/21/27/51 mismatch look like a bug.
2664
+ call({ action: 'longterm.workspaces' }).catch(() => []),
2665
+ ])
2666
+ .then(([memories, sessions, workspaces]) => {
2667
+ if (!alive) return
2668
+ const items = Array.isArray(sessions) ? sessions : []
2669
+ const stores = Array.isArray(workspaces) ? workspaces : []
2670
+ const memoryRows = Array.isArray(memories) ? memories : []
2671
+ // The global store's size, so the breakdown adds up to the total instead
2672
+ // of leaving the difference unexplained.
2673
+ const globalRows = memoryRows.filter(row => row.scope === 'global').length
2674
+ setCounts({
2675
+ memories: memoryRows.length,
2676
+ memoryActive: memoryRows.filter(row => row.archived !== true).length,
2677
+ memoryArchived: memoryRows.filter(row => row.archived === true).length,
2678
+ memoryGlobal: globalRows,
2679
+ sessions: items.length,
2680
+ snapshots: items.reduce((sum, item) => sum + (item.checkpoints ?? 0), 0),
2681
+ archived: items.filter(item => item.archived === true).length,
2682
+ // Each store's own size, so the total has a visible decomposition.
2683
+ stores: stores.map(store => ({
2684
+ cwd: store.cwd ?? store.key,
2685
+ entries: store.entries ?? 0,
2686
+ })),
2687
+ })
2688
+ })
2689
+ .catch(failure => alive && setError(failure?.message ?? String(failure)))
2690
+ return () => {
2691
+ alive = false
2692
+ }
2693
+ }, [rev])
2694
+
2695
+ // One screen is open: render it, with a way back.
2696
+ if (open) {
2697
+ const screens = {
2698
+ snapshots: SnapshotSection,
2699
+ memory: MemorySection,
2700
+ archived: ArchivedSection,
2701
+ }
2702
+ const Screen = screens[open]
2703
+ return h(
2704
+ 'div',
2705
+ { className: 'dss-panel' },
2706
+ h(
2707
+ 'div',
2708
+ { className: 'dss-panel-nav' },
2709
+ h(
2710
+ 'button',
2711
+ { type: 'button', className: 'dss-btn dss-small dss-ghost', onClick: () => setOpen(null) },
2712
+ `‹ ${t('panel.title')}`,
2713
+ ),
2714
+ ),
2715
+ Screen ? h(Screen, { t }) : null,
2716
+ )
2717
+ }
2718
+
2719
+ /** @param {string} key screen key @param {string} title row title @param {string} hint row hint */
2720
+ const row = (key, title, hint, meta) =>
2721
+ h(
2722
+ 'button',
2723
+ {
2724
+ key,
2725
+ type: 'button',
2726
+ className: 'dss-nav-row',
2727
+ onClick: () => setOpen(key),
2728
+ },
2729
+ h(
2730
+ 'div',
2731
+ { className: 'dss-nav-main' },
2732
+ h('div', { className: 'dss-nav-title' }, title),
2733
+ h('div', { className: 'dss-nav-hint' }, hint),
2734
+ ),
2735
+ meta === undefined ? h(Icons.chevron, null) : h('div', { className: 'dss-nav-meta' }, String(meta)),
2736
+ )
2737
+
2738
+ return h(
2739
+ 'div',
2740
+ { className: 'dss-panel' },
2741
+ h(
2742
+ 'div',
2743
+ { className: 'dss-section-head' },
2744
+ h('h2', null, t('panel.title')),
2745
+ h('div', { className: 'dss-section-desc' }, t('panel.description')),
2746
+ ),
2747
+ error ? h('div', { className: 'dss-dialog-error' }, error) : null,
2748
+ // A summary strip, because three rows alone left most of the column empty.
2749
+ // The numbers are the same ones the rows carry, so it adds orientation
2750
+ // without inventing a second source of truth.
2751
+ //
2752
+ // Each figure states what it counts. Showing bare numbers is how the earlier
2753
+ // mismatch became a bug report: the user saw 17 here and 21 there, both
2754
+ // labelled "memory", with nothing on screen saying one was the global store
2755
+ // and the other was global plus one workspace.
2756
+ h(
2757
+ 'div',
2758
+ { className: 'dss-stat-strip' },
2759
+ ...[
2760
+ ['panel.statSessions', counts?.sessions, 'panel.statSessionsHint'],
2761
+ ['panel.statSnapshots', counts?.snapshots, 'panel.statSnapshotsHint'],
2762
+ ['panel.statMemories', counts?.memories, 'panel.statMemoriesHint'],
2763
+ ['panel.statArchived', counts?.archived, 'panel.statArchivedHint'],
2764
+ ].map(([label, value, hint]) =>
2765
+ h(
2766
+ 'div',
2767
+ { className: 'dss-stat', key: label, title: t(hint) },
2768
+ h('div', { className: 'dss-stat-value' }, value === undefined ? '—' : String(value)),
2769
+ h('div', { className: 'dss-stat-label' }, t(label)),
2770
+ h('div', { className: 'dss-stat-hint' }, t(hint)),
2771
+ ),
2772
+ ),
2773
+ ),
2774
+ // The decomposition, so the total is checkable rather than a number to trust.
2775
+ counts?.stores?.length
2776
+ ? h(
2777
+ 'div',
2778
+ { className: 'dss-breakdown' },
2779
+ h('div', { className: 'dss-group-title' }, t('panel.breakdownTitle')),
2780
+ h(
2781
+ 'div',
2782
+ { className: 'dss-breakdown-row' },
2783
+ h('span', { className: 'dss-breakdown-key' }, t('panel.breakdownGlobal')),
2784
+ h('span', { className: 'dss-breakdown-value' }, String(counts.memoryGlobal ?? '—')),
2785
+ ),
2786
+ ...counts.stores.map(store =>
2787
+ h(
2788
+ 'div',
2789
+ { className: 'dss-breakdown-row', key: store.cwd },
2790
+ h(
2791
+ 'span',
2792
+ { className: 'dss-breakdown-key', title: store.cwd },
2793
+ String(store.cwd).split(/[\\/]/).filter(Boolean).pop() ?? store.cwd,
2794
+ ),
2795
+ h('span', { className: 'dss-breakdown-value' }, String(store.entries)),
2796
+ ),
2797
+ ),
2798
+ h('div', { className: 'dss-breakdown-hint' }, t('panel.breakdownHint')),
2799
+ )
2800
+ : null,
2801
+ h(
2802
+ 'div',
2803
+ { className: 'dss-nav' },
2804
+ row('snapshots', t('panel.snapshots'), t('panel.snapshotsHint'), counts?.snapshots),
2805
+ row('memory', t('panel.memory'), t('panel.memoryHint'), counts?.memories),
2806
+ row('archived', t('panel.archived'), t('panel.archivedHint'), counts?.archived),
2807
+ ),
2808
+ h(
2809
+ 'div',
2810
+ { className: 'dss-panel-footer' },
2811
+ h('span', null, t('panel.sessionsCount', { n: counts?.sessions ?? 0 })),
2812
+ // A way to find the source. dsh-connect-trae puts a GitHub link in the same
2813
+ // place, and it is the one line in a panel like this that answers "who made
2814
+ // this and where do I report a problem".
2815
+ h(
2816
+ 'a',
2817
+ {
2818
+ className: 'dss-panel-link',
2819
+ href: 'https://github.com/heixiua/dsh-session-suite',
2820
+ target: '_blank',
2821
+ rel: 'noreferrer noopener',
2822
+ },
2823
+ t('panel.repoLink'),
2824
+ ),
2825
+ ),
2826
+ )
2827
+ }
2828
+
2829
+ // --------------------------------------------------- archived sessions
2830
+
2831
+ /**
2832
+ * Archived sessions, with a way back.
2833
+ *
2834
+ * Archiving existed as a Host command but had no surface here, so a session
2835
+ * could be archived and then be unreachable — the sidebar hides it and the only
2836
+ * way back was the Host's own menu, which is disabled in the context where the
2837
+ * archive happened. This page is the missing half: it lists what the sidebar is
2838
+ * hiding and offers restore.
2839
+ *
2840
+ * Restore, not delete, is the primary action. Archived rows are still complete
2841
+ * sessions; the common mistake is to treat "hidden" as "gone" and destroy data
2842
+ * to tidy a list.
2843
+ *
2844
+ * @param {any} props slot props
2845
+ * @returns {any} section element
2846
+ */
2847
+ function ArchivedSection(props) {
2848
+ const t = props.t ?? (key => key)
2849
+ const rev = useStore(revision)
2850
+ const [rows, setRows] = useState(null)
2851
+ const [error, setError] = useState(null)
2852
+ const [busy, setBusy] = useState(null)
2853
+
2854
+ useEffect(() => {
2855
+ let alive = true
2856
+ // `list` answers with a bare array; see the note in SuitePanel.
2857
+ call({ action: 'list' })
2858
+ .then(result => {
2859
+ if (!alive) return
2860
+ const items = Array.isArray(result) ? result : []
2861
+ setRows(items.filter(item => item.archived === true))
2862
+ })
2863
+ .catch(failure => alive && setError(failure?.message ?? String(failure)))
2864
+ return () => {
2865
+ alive = false
2866
+ }
2867
+ }, [rev])
2868
+
2869
+ /** @param {string} sessionId session to restore */
2870
+ const restore = async sessionId => {
2871
+ setBusy(sessionId)
2872
+ setError(null)
2873
+ try {
2874
+ await call({ action: 'restoreSession', sessionId })
2875
+ notify(t('archived.restored'))
2876
+ bump()
2877
+ } catch (failure) {
2878
+ setError(failure?.message ?? t('archived.restoreFailed'))
2879
+ } finally {
2880
+ setBusy(null)
2881
+ }
2882
+ }
2883
+
2884
+ const head = h(
2885
+ 'div',
2886
+ { className: 'dss-section-head' },
2887
+ h('h2', null, t('archived.title')),
2888
+ h('div', { className: 'dss-section-desc' }, t('archived.description')),
2889
+ )
2890
+
2891
+ if (error) return h('div', { className: 'dss-section' }, head, h('div', { className: 'dss-dialog-error' }, error))
2892
+ if (!rows) return h('div', { className: 'dss-section' }, head, h('div', { className: 'dss-empty' }, t('settings.loading')))
2893
+
2894
+ const body =
2895
+ rows.length === 0
2896
+ ? h('div', { className: 'dss-empty' }, t('archived.empty'))
2897
+ : h(
2898
+ 'div',
2899
+ { className: 'dss-card' },
2900
+ ...rows.map(entry =>
2901
+ h(
2902
+ 'div',
2903
+ { className: 'dss-row', key: entry.sessionId },
2904
+ h(
2905
+ 'div',
2906
+ { className: 'dss-row-main' },
2907
+ h('div', { className: 'dss-memory-text' }, entry.title || entry.sessionId),
2908
+ h(
2909
+ 'div',
2910
+ { className: 'dss-row-meta' },
2911
+ h('span', { className: 'dss-badge dss-temp' }, t('settings.archived')),
2912
+ typeof entry.checkpoints === 'number'
2913
+ ? h('span', null, t('settings.checkpointCount', { n: entry.checkpoints }))
2914
+ : null,
2915
+ ),
2916
+ ),
2917
+ h(
2918
+ 'div',
2919
+ { className: 'dss-row-actions' },
2920
+ h(
2921
+ 'button',
2922
+ {
2923
+ type: 'button',
2924
+ className: 'dss-btn dss-small',
2925
+ disabled: busy === entry.sessionId,
2926
+ onClick: () => restore(entry.sessionId),
2927
+ },
2928
+ busy === entry.sessionId ? t('settings.working') : t('archived.restore'),
2929
+ ),
2930
+ ),
2931
+ ),
2932
+ ),
2933
+ )
2934
+
2935
+ return h('div', { className: 'dss-section' }, head, body)
2936
+ }
2937
+
2383
2938
  function MemorySection(props) {
2384
2939
  const t = props.t
2385
2940
  const rev = useStore(revision)
@@ -2571,10 +3126,14 @@ window.__ModuleLoader__.load({
2571
3126
  h('span', { className: 'dss-row-meta' }, t('memory.filterCount', { n: (items ?? []).length })),
2572
3127
  )
2573
3128
 
3129
+ // An empty queue used to render nothing at all, which is indistinguishable
3130
+ // from a broken feature: the user cannot tell "the model judged this turn
3131
+ // unremarkable" from "every memory model failed" or "a filter ate the
3132
+ // result". When there is nothing pending, say why.
3133
+ const extraction = status?.extraction
2574
3134
  const pendingBlock =
2575
- pending.length === 0
2576
- ? null
2577
- : h(
3135
+ pending.length > 0
3136
+ ? h(
2578
3137
  'div',
2579
3138
  { className: 'dss-mem-pending' },
2580
3139
  h('div', { className: 'dss-group-title' }, t('memory.pendingTitle')),
@@ -2616,6 +3175,39 @@ window.__ModuleLoader__.load({
2616
3175
  ),
2617
3176
  ),
2618
3177
  )
3178
+ : h(
3179
+ // Always render this block, even when extraction has never run.
3180
+ //
3181
+ // It used to render only when a trace existed, so a freshly started
3182
+ // Host showed NOTHING here — indistinguishable from a broken feature,
3183
+ // and exactly the question the user then asked. An empty state has to
3184
+ // say why it is empty.
3185
+ 'div',
3186
+ { className: 'dss-mem-pending' },
3187
+ h('div', { className: 'dss-group-title' }, t('memory.pendingTitle')),
3188
+ h(
3189
+ 'div',
3190
+ { className: 'dss-mem-pending-hint' },
3191
+ extraction
3192
+ ? t('memory.extractionIdle', {
3193
+ reason: extraction.reason || t('memory.extractionUnknown'),
3194
+ })
3195
+ : t('memory.extractionNotYet'),
3196
+ ),
3197
+ extraction?.candidates?.length
3198
+ ? h(
3199
+ 'div',
3200
+ { className: 'dss-row-meta' },
3201
+ h('span', null, t('memory.extractionTried', { n: extraction.candidates.length })),
3202
+ extraction.queued !== undefined
3203
+ ? h('span', null, t('memory.extractionQueued', { n: extraction.queued }))
3204
+ : null,
3205
+ extraction.rejected !== undefined
3206
+ ? h('span', null, t('memory.extractionRejected', { n: extraction.rejected }))
3207
+ : null,
3208
+ )
3209
+ : null,
3210
+ )
2619
3211
 
2620
3212
  const listBlock =
2621
3213
  visible.length === 0
@@ -2636,6 +3228,10 @@ window.__ModuleLoader__.load({
2636
3228
  { className: 'dss-row-meta' },
2637
3229
  h('span', { className: 'dss-badge' + (entry.scope === 'global' ? ' dss-locked' : '') }, entry.scope === 'global' ? t('memory.global') : t('memory.workspace')),
2638
3230
  entry.kind === 'rule' ? h('span', { className: 'dss-badge dss-temp' }, t('memory.rule')) : null,
3231
+ // An episode is a dated event, not a truth. Labelling it
3232
+ // separately is what lets the user tell "this happened once"
3233
+ // from "this is how things are".
3234
+ entry.kind === 'episode' ? h('span', { className: 'dss-badge' }, t('memory.episode')) : null,
2639
3235
  entry.summary ? h('span', { className: 'dss-badge' }, t('memory.summary')) : null,
2640
3236
  entry.frozen ? h('span', { className: 'dss-badge dss-locked' }, t('memory.frozen')) : null,
2641
3237
  entry.archived ? h('span', { className: 'dss-badge dss-temp' }, t('memory.archived')) : null,
@@ -2987,6 +3583,29 @@ window.__ModuleLoader__.load({
2987
3583
  )
2988
3584
  }
2989
3585
 
3586
+ // ---------------------------------------------------- sidebar entry
3587
+ //
3588
+ // A first-class sidebar entry, matching how dsh-connect-trae,
3589
+ // dsh-connect-workbuddy and dsh-context present themselves: an icon in
3590
+ // `sidebar.panellist` plus a panel under the same key in `main`. Both are
3591
+ // additive (`replaceRisk: none` on the list, and the key is one only this
3592
+ // plugin claims), so nothing shipped is shadowed.
3593
+ //
3594
+ // Why a panel at all: the features already existed but were reachable only
3595
+ // through Settings → one of two sections, three steps from anywhere. The
3596
+ // panel is pure navigation — it does not reimplement the sections, it opens
3597
+ // them, so there is one implementation of every screen.
3598
+ ctx.slots.inject('sidebar.panellist', () =>
3599
+ ctx.slots.register(
3600
+ { name: 'sidebar.panellist', id: 'session-suite', order: 25, locale: NS },
3601
+ withT(SuiteIcon),
3602
+ ),
3603
+ )
3604
+
3605
+ ctx.slots.inject('main', () =>
3606
+ ctx.slots.register({ name: 'main', key: 'session-suite' }, withT(SuitePanel)),
3607
+ )
3608
+
2990
3609
  ctx.slots.inject('conversation.chat.assistant-actions', () =>
2991
3610
  ctx.slots.register(
2992
3611
  { name: 'conversation.chat.assistant-actions', id: 'session-suite-rewind', order: 20, locale: NS },
@@ -3005,31 +3624,15 @@ window.__ModuleLoader__.load({
3005
3624
  ctx.slots.register({ name: 'shell.overlay', id: 'session-suite-overlay', order: 40 }, SuiteOverlay),
3006
3625
  )
3007
3626
 
3008
- ctx.slots.inject('settings.section', () =>
3009
- ctx.slots.register(
3010
- {
3011
- name: 'settings.section',
3012
- id: 'session-snapshots',
3013
- order: 500,
3014
- locale: NS,
3015
- label: () => t('settings.title'),
3016
- },
3017
- withT(SnapshotSection),
3018
- ),
3019
- )
3020
-
3021
- ctx.slots.inject('settings.section', () =>
3022
- ctx.slots.register(
3023
- {
3024
- name: 'settings.section',
3025
- id: 'long-term-memory',
3026
- order: 520,
3027
- locale: NS,
3028
- label: () => t('memory.title'),
3029
- },
3030
- withT(MemorySection),
3031
- ),
3032
- )
3627
+ // The three settings sections are gone on purpose.
3628
+ //
3629
+ // They were the only way in before the sidebar panel existed, so the same
3630
+ // three screens appeared twice: once under Settings and once behind the
3631
+ // sidebar icon. Two entry points for one screen is not convenience, it is
3632
+ // two places to keep consistent and two places for the user to wonder about.
3633
+ // The panel is the primary surface now; these registrations were removed
3634
+ // rather than hidden behind a flag, because a dead section in Settings is
3635
+ // worse than an absent one.
3033
3636
  }
3034
3637
  },
3035
3638
  }