dsh-session-mng 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -22,6 +22,10 @@ window.__ModuleLoader__.load({
22
22
  const STYLE_ID = 'dsh-session-mng-style'
23
23
  const GROUP_PAGE_SIZE = 50
24
24
  const DEFAULT_COLLAPSE_AFTER = 100
25
+ /** Debounce between typing in the search box and applying the query. */
26
+ const SEARCH_DEBOUNCE_MS = 250
27
+ /** Ceiling on concurrent workspace-page requests (expand-all / focus). */
28
+ const MAX_CONCURRENT_PAGES = 3
25
29
 
26
30
  const DICT = {
27
31
  zh: {
@@ -48,7 +52,9 @@ window.__ModuleLoader__.load({
48
52
  showBlank: '显示空会话',
49
53
  cleanupBlank: '清理空会话({n})',
50
54
  cleanupBlankHint: '批量删除已归档的空会话;未归档的空会话需先归档。',
51
- blankActiveHint: '另有 {n} 个未归档空会话,归档后才能清理。',
55
+ cleanupBlankLockedHint: '有 {n} 个已归档空会话已在 DSH 中打开,重启后才能清理。',
56
+ archiveBlank: '归档空会话({n})',
57
+ archiveBlankHint: '把未归档的空会话批量归档;归档后即可用「清理空会话」彻底删除。',
52
58
  hiddenBlankCount: '已隐藏 {n} 个空会话',
53
59
  selectAll: '全选当前列表',
54
60
  expandAll: '展开全部',
@@ -56,6 +62,15 @@ window.__ModuleLoader__.load({
56
62
  loadMore: '加载更多(剩余 {n} 条)',
57
63
  selectionScopeHint: '选择仅作用于当前筛选与展开结果。',
58
64
  searchAutoExpand: '搜索时自动展开命中工作区。',
65
+ groupLoading: '正在加载会话…',
66
+ groupLoadFailed: '加载失败:{message}',
67
+ groupRetry: '重试',
68
+ loadedOf: '已加载 {loaded}/{total}',
69
+ partialSelection: '另有 {n} 条未加载,未纳入全选',
70
+ parentNotLoaded: '来源父会话未加载',
71
+ overviewLoadFailed: '读取总览失败:{message}',
72
+ sizesPending: '正在统计大小…',
73
+ sizesUnavailable: '大小统计暂不可用,请稍后重试。',
59
74
  selectRow: '选择会话 {name}',
60
75
  justNow: '刚刚',
61
76
  titleApplied: '标题已写入。',
@@ -212,7 +227,9 @@ window.__ModuleLoader__.load({
212
227
  showBlank: 'Show empty sessions',
213
228
  cleanupBlank: 'Clean up empty sessions ({n})',
214
229
  cleanupBlankHint: 'Permanently delete archived empty sessions; unarchived empty sessions must be archived first.',
215
- blankActiveHint: '{n} unarchived empty sessions must be archived before cleanup.',
230
+ cleanupBlankLockedHint: '{n} archived empty sessions are open in this DSH process; restart DSH before cleaning them up.',
231
+ archiveBlank: 'Archive empty sessions ({n})',
232
+ archiveBlankHint: 'Archive every unarchived empty session in one batch; they can then be cleaned up with "Clean up empty sessions".',
216
233
  hiddenBlankCount: '{n} empty sessions hidden',
217
234
  selectAll: 'Select all shown',
218
235
  expandAll: 'Expand all',
@@ -220,6 +237,15 @@ window.__ModuleLoader__.load({
220
237
  loadMore: 'Load {n} more',
221
238
  selectionScopeHint: 'Selection applies to the current filter and expanded workspaces.',
222
239
  searchAutoExpand: 'Matching workspaces expand automatically while searching.',
240
+ groupLoading: 'Loading sessions…',
241
+ groupLoadFailed: 'Could not load: {message}',
242
+ groupRetry: 'Retry',
243
+ loadedOf: '{loaded}/{total} loaded',
244
+ partialSelection: '{n} more not loaded; not included in Select all',
245
+ parentNotLoaded: 'Source session not loaded',
246
+ overviewLoadFailed: 'Could not load the overview: {message}',
247
+ sizesPending: 'Measuring sizes…',
248
+ sizesUnavailable: 'Size totals are not ready; try again in a moment.',
223
249
  selectRow: 'Select session {name}',
224
250
  justNow: 'just now',
225
251
  titleApplied: 'Title applied.',
@@ -356,10 +382,13 @@ window.__ModuleLoader__.load({
356
382
 
357
383
  const CSS = `
358
384
  .dsm-page{display:flex;flex-direction:column;gap:12px;font:inherit;color:var(--dsw-alias-label-primary);min-width:0}
359
- .dsm-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}
385
+ /* Page head: title and its actions share the first row; the subtitles use the
386
+ full width below, so the buttons sit in the top-right corner. */
387
+ .dsm-head{display:flex;flex-direction:column;gap:2px;min-width:0}
388
+ .dsm-head-row{display:flex;align-items:center;justify-content:flex-start;gap:12px;flex-wrap:wrap;min-width:0}
360
389
  .dsm-title{margin:0;font-size:17px;font-weight:600;line-height:24px}
361
390
  .dsm-sub{margin:2px 0 0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}
362
- .dsm-head-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
391
+ .dsm-head-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-left:auto}
363
392
  .dsm-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:28px;padding:0 10px;
364
393
  border:1px solid var(--dsw-alias-border-l1);border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary);
365
394
  font:inherit;font-size:12px;line-height:18px;cursor:pointer;white-space:nowrap}
@@ -387,6 +416,12 @@ window.__ModuleLoader__.load({
387
416
  .dsm-note.warn{border-color:var(--dsw-alias-state-warn-primary)}
388
417
  .dsm-note-actions{display:flex;gap:6px;flex-wrap:wrap;flex:0 0 auto}
389
418
  .dsm-note detail{flex:1 1 100%}
419
+ /* Operation-result toast: floats above the pane so scrolling never hides it. */
420
+ .dsm-toast{position:fixed;bottom:18px;right:18px;z-index:1200;width:max-content;
421
+ max-width:min(460px,calc(100vw - 36px));padding:10px 12px;border-radius:8px;
422
+ background:var(--dsw-alias-bg-layer-2);box-shadow:0 14px 40px rgba(0,0,0,.45);
423
+ animation:dsm-toast-in .16s ease-out}
424
+ @keyframes dsm-toast-in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
390
425
  .dsm-sticky{position:sticky;top:0;z-index:3;display:flex;flex-direction:column;gap:8px;
391
426
  padding:6px 0 8px;background:var(--dsw-alias-bg-layer-2);border-bottom:1px solid var(--dsw-alias-border-l1)}
392
427
  .dsm-batch{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 10px;border-radius:7px;
@@ -417,6 +452,7 @@ window.__ModuleLoader__.load({
417
452
  .dsm-group-title{font-size:12px;font-weight:600;line-height:18px}
418
453
  .dsm-group-path{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);word-break:break-all}
419
454
  .dsm-group-meta{margin-left:auto;font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
455
+ .dsm-group-loaded{padding:7px 10px;border-top:1px solid var(--dsw-alias-border-l1);display:flex;align-items:center;gap:8px;flex-wrap:wrap}
420
456
  .dsm-warn{color:var(--dsw-alias-state-warn-primary);font-size:11px}
421
457
  .dsm-row{display:grid;grid-template-columns:18px minmax(0,1fr) auto auto;align-items:center;gap:8px;
422
458
  padding:7px 10px;border-top:1px solid var(--dsw-alias-border-l1);min-width:0}
@@ -438,7 +474,10 @@ window.__ModuleLoader__.load({
438
474
  .dsm-empty{padding:22px 10px;text-align:center;font-size:12px;color:var(--dsw-alias-label-secondary);
439
475
  border:1px dashed var(--dsw-alias-border-l2);border-radius:8px}
440
476
  .dsm-detail{display:flex;flex-direction:column;gap:12px}
441
- .dsm-detail-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;flex-wrap:wrap}
477
+ /* The title row and its actions stay in view while the detail page scrolls. */
478
+ .dsm-detail-head{position:sticky;top:0;z-index:2;display:flex;align-items:flex-start;justify-content:space-between;
479
+ gap:10px;flex-wrap:wrap;padding:6px 0 8px;background:var(--dsw-alias-bg-layer-2);
480
+ border-bottom:1px solid var(--dsw-alias-border-l1)}
442
481
  .dsm-detail-title{margin:0;font-size:16px;line-height:23px;font-weight:600;word-break:break-word}
443
482
  .dsm-card{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-1);padding:10px}
444
483
  .dsm-card h3{margin:0 0 8px;font-size:12px;line-height:18px;font-weight:600}
@@ -581,6 +620,7 @@ window.__ModuleLoader__.load({
581
620
  : t('noAdapterUnknown')
582
621
  }
583
622
  if (code === 'title-invalid') return t('titleInvalid')
623
+ if (code === 'sizes-pending') return t('sizesUnavailable')
584
624
  if (code === 'timeout') return t('timeout')
585
625
  if (code === 'llm-error') return t('llmError', { message: error?.message ?? '' })
586
626
  if (code === 'session-active') return t('sessionActive')
@@ -622,6 +662,26 @@ window.__ModuleLoader__.load({
622
662
  const getState = (includeSubagents, signal) =>
623
663
  request('GET', `/state?subagents=${includeSubagents ? '1' : '0'}`, undefined, signal)
624
664
 
665
+ const getOverview = (includeSubagents, signal) =>
666
+ request('GET', `/overview?subagents=${includeSubagents ? '1' : '0'}`, undefined, signal)
667
+
668
+ const getBlanks = (includeSubagents, signal) =>
669
+ request('GET', `/blanks?subagents=${includeSubagents ? '1' : '0'}`, undefined, signal)
670
+
671
+ const getSizes = (workspaceId, includeSubagents, signal) =>
672
+ request(
673
+ 'GET',
674
+ `/sizes?workspaceId=${encodeURIComponent(workspaceId)}&subagents=${includeSubagents ? '1' : '0'}`,
675
+ undefined,
676
+ signal,
677
+ )
678
+
679
+ const getSessions = (params, signal) => request('GET', `/sessions?${queryOf(params)}`, undefined, signal)
680
+
681
+ const getSearch = (params, signal) => request('GET', `/search?${queryOf(params)}`, undefined, signal)
682
+
683
+ const getSession = (id, signal) => request('GET', `/session?id=${encodeURIComponent(id)}`, undefined, signal)
684
+
625
685
  const post = (action, body, signal) => request('POST', `/${action}`, body, signal)
626
686
 
627
687
  function useLocaleTick(ctx) {
@@ -655,11 +715,33 @@ window.__ModuleLoader__.load({
655
715
  return h('span', null, h('span', { className: 'dsm-spin', 'aria-hidden': 'true' }), ' ', label ?? t('working'))
656
716
  }
657
717
 
718
+ /** How long a plain success toast stays before hiding itself. */
719
+ const NOTICE_HIDE_MS = 5000
720
+
721
+ /**
722
+ * Operation-result toast.
723
+ *
724
+ * It floats above the settings pane (instead of sitting in the scroll flow),
725
+ * so a result is visible no matter where the list is scrolled. A plain
726
+ * success toast hides itself after a few seconds; failures and notices with
727
+ * an action stay until the user dismisses them, because they carry details
728
+ * worth reading.
729
+ */
658
730
  function Notice({ notice, t, onDismiss }) {
731
+ const dismiss = React.useRef(onDismiss)
732
+ dismiss.current = onDismiss
733
+ const transient = notice !== null && notice !== undefined
734
+ && notice.tone !== 'error' && notice.tone !== 'warn'
735
+ && notice.details === undefined && notice.action === undefined
736
+ React.useEffect(() => {
737
+ if (!transient) return undefined
738
+ const timer = setTimeout(() => dismiss.current?.(), NOTICE_HIDE_MS)
739
+ return () => clearTimeout(timer)
740
+ }, [notice, transient])
659
741
  if (notice === null || notice === undefined) return null
660
742
  return h(
661
743
  'div',
662
- { className: `dsm-note ${notice.tone ?? ''}`, role: 'status' },
744
+ { className: `dsm-note dsm-toast ${notice.tone ?? ''}`, role: 'status' },
663
745
  h('div', { style: { flex: '1 1 auto', minWidth: 0 } },
664
746
  h('div', null, notice.text),
665
747
  notice.detail ? h('div', { className: 'dsm-hint', style: { marginTop: 3 } }, notice.detail) : null,
@@ -685,7 +767,21 @@ window.__ModuleLoader__.load({
685
767
  function Row({ row, selected, onToggle, onOpen, t }) {
686
768
  const depth = row.branchDepth ?? 0
687
769
  const branch = row.kind === 'fork'
770
+ const glyph = depth > 0 || row.branchOrphan === true
688
771
  const forkCount = row.forkCount ?? 0
772
+ // A branch whose parent is not among the loaded rows keeps the glyph but
773
+ // must not claim the parent is gone: the three cases read differently.
774
+ const parentNote = branch !== true
775
+ ? ''
776
+ : row.branchParentTitle
777
+ ? `${t('fromParent', { title: row.branchParentTitle })} · `
778
+ : row.branchParentMissing
779
+ ? `${t('parentMissing')} · `
780
+ : row.branchParentNotLoaded
781
+ ? `${t('parentNotLoaded')} · `
782
+ : row.branchDepth === undefined && row.parentPresent === false
783
+ ? `${t('parentMissing')} · `
784
+ : ''
689
785
  const badges = []
690
786
  if (row.running) badges.push(h(Badge, { key: 'run', tone: 'run' }, t('running')))
691
787
  if (row.archived) badges.push(h(Badge, { key: 'arch' }, t('archived')))
@@ -717,17 +813,13 @@ window.__ModuleLoader__.load({
717
813
  h(
718
814
  'span',
719
815
  { className: 'dsm-row-title' },
720
- depth > 0 ? h('span', { className: 'dsm-branch-glyph', 'aria-hidden': 'true' }, '↳') : null,
816
+ glyph ? h('span', { className: 'dsm-branch-glyph', 'aria-hidden': 'true' }, '↳') : null,
721
817
  row.title === '' ? t('untitled') : row.title,
722
818
  ),
723
819
  h(
724
820
  'span',
725
821
  { className: 'dsm-row-meta' },
726
- branch && row.parentTitle !== ''
727
- ? `${t('fromParent', { title: row.parentTitle })} · `
728
- : branch && row.parentPresent === false
729
- ? `${t('parentMissing')} · `
730
- : '',
822
+ parentNote,
731
823
  row.cwd === '' ? t('noCwd') : row.cwd,
732
824
  ' · ',
733
825
  row.id,
@@ -743,8 +835,15 @@ window.__ModuleLoader__.load({
743
835
  )
744
836
  }
745
837
 
746
- function Group({ group, selected, onToggle, onSelect, onOpen, onMore, t, locked }) {
747
- const remaining = group.rows.length - group.renderedRows.length
838
+ function Group({ group, selected, onToggle, onSelect, onOpen, onMore, onRetry, t, locked }) {
839
+ const total = Number.isFinite(group.total) ? group.total : group.rows.length
840
+ // Paged groups render everything they have loaded; the legacy fallback
841
+ // renders a 0.1.x-style 50-row window per group.
842
+ const rendered = Array.isArray(group.renderedRows) ? group.renderedRows : group.rows
843
+ const remaining = Math.max(0, total - rendered.length)
844
+ const size = formatBytesOrDash(group.bytes)
845
+ const meta = `${t('groupLine', { n: total, size })}${group.expanded === true && remaining > 0 ? ` · ${t('loadedOf', { loaded: rendered.length, total })}` : ''}`
846
+ const empty = rendered.length === 0
748
847
  return h(
749
848
  'section',
750
849
  { className: 'dsm-group' },
@@ -777,19 +876,30 @@ window.__ModuleLoader__.load({
777
876
  ? h('div', { className: 'dsm-warn' }, t('workspaceMissing'))
778
877
  : null,
779
878
  ),
780
- h('div', { className: 'dsm-group-meta' }, t('groupLine', { n: group.rows.length, size: formatBytes(group.bytes) })),
879
+ h('div', { className: 'dsm-group-meta' }, meta),
781
880
  ),
782
- group.renderedRows.map((row) =>
783
- h(Row, {
784
- key: row.id,
785
- row,
786
- selected: selected.has(row.id),
787
- onToggle: () => onSelect(row.id),
788
- onOpen: () => onOpen(row.id),
789
- t,
790
- }),
791
- ),
792
- group.expanded === true && remaining > 0
881
+ group.expanded === true && empty && (group.phase === 'loading' || group.phase === 'idle')
882
+ ? h('p', { className: 'dsm-group-loaded dsm-hint' }, t('groupLoading'))
883
+ : null,
884
+ group.expanded === true && group.phase === 'error'
885
+ ? h('div', { className: 'dsm-group-loaded' },
886
+ h('span', { className: 'dsm-error' }, t('groupLoadFailed', { message: errorText(t, group.error) })),
887
+ h('button', { type: 'button', className: 'dsm-btn', onClick: onRetry }, t('groupRetry')),
888
+ )
889
+ : null,
890
+ group.expanded === true
891
+ ? rendered.map((row) =>
892
+ h(Row, {
893
+ key: row.id,
894
+ row,
895
+ selected: selected.has(row.id),
896
+ onToggle: () => onSelect(row.id),
897
+ onOpen: () => onOpen(row.id),
898
+ t,
899
+ }),
900
+ )
901
+ : null,
902
+ group.expanded === true && remaining > 0 && group.phase === 'ready'
793
903
  ? h(
794
904
  'button',
795
905
  { type: 'button', className: 'dsm-btn ghost dsm-more', onClick: onMore },
@@ -982,30 +1092,44 @@ window.__ModuleLoader__.load({
982
1092
  // Delete scope (shared by single and batch)
983
1093
  // ---------------------------------------------------------------------
984
1094
 
985
- function DeleteScope({ ctx, t, ids, onConfirmed, onCancel, busy }) {
1095
+ function DeleteScope({ ctx, t, ids, maxBatch, onConfirmed, onCancel, busy }) {
986
1096
  const [phase, setPhase] = React.useState('loading')
987
1097
  const [preview, setPreview] = React.useState(null)
988
1098
  const [error, setError] = React.useState(null)
989
1099
  const [checked, setChecked] = React.useState(false)
990
1100
  const idsKey = ids.join('\u0000')
991
1101
 
1102
+ // One confirmation may name more sessions than a single request admits
1103
+ // (the empty-session shortcut does). Preview in batches and merge the
1104
+ // scopes so the dialog still shows one combined result.
992
1105
  React.useEffect(() => {
993
1106
  const controller = new AbortController()
994
1107
  setPhase('loading')
995
1108
  setChecked(false)
996
- post('delete/preview', { sessionIds: ids }, controller.signal)
997
- .then((value) => {
1109
+ const size = Number.isFinite(maxBatch) && maxBatch > 0 ? maxBatch : 50
1110
+ const merge = async () => {
1111
+ const merged = { roots: [], errors: [], sessions: [], descendantCount: 0, forkChildren: [], totalBytes: 0 }
1112
+ for (let index = 0; index < ids.length; index += size) {
1113
+ const value = await post('delete/preview', { sessionIds: ids.slice(index, index + size) }, controller.signal)
998
1114
  if (controller.signal.aborted) return
999
- setPreview(value)
1000
- setPhase('ready')
1001
- })
1002
- .catch((failure) => {
1003
- if (failure?.name === 'AbortError') return
1004
- setError(errorText(t, failure))
1005
- setPhase('error')
1006
- })
1115
+ merged.roots.push(...(value?.roots ?? []))
1116
+ merged.errors.push(...(value?.errors ?? []))
1117
+ merged.sessions.push(...(value?.sessions ?? []))
1118
+ merged.descendantCount += value?.descendantCount ?? 0
1119
+ merged.forkChildren.push(...(value?.forkChildren ?? []))
1120
+ merged.totalBytes += value?.totalBytes ?? 0
1121
+ }
1122
+ if (controller.signal.aborted) return
1123
+ setPreview(merged)
1124
+ setPhase('ready')
1125
+ }
1126
+ merge().catch((failure) => {
1127
+ if (failure?.name === 'AbortError') return
1128
+ setError(errorText(t, failure))
1129
+ setPhase('error')
1130
+ })
1007
1131
  return () => controller.abort()
1008
- }, [idsKey, t])
1132
+ }, [idsKey, maxBatch, t])
1009
1133
 
1010
1134
  React.useEffect(() => {
1011
1135
  const onKeyDown = (event) => {
@@ -1120,12 +1244,12 @@ window.__ModuleLoader__.load({
1120
1244
  // Detail view
1121
1245
  // ---------------------------------------------------------------------
1122
1246
 
1123
- function DetailView({ ctx, t, state, row, busy, onBack, onChanged, onOpen, onArchive, onRestore, onMove, onDelete }) {
1247
+ function DetailView({ ctx, t, overview, limits, row, busy, onBack, onChanged, onOpen, onArchive, onRestore, onMove, onDelete }) {
1124
1248
  const [copied, setCopied] = React.useState(false)
1125
1249
  const [showDelete, setShowDelete] = React.useState(false)
1126
1250
  const deleteIds = React.useMemo(() => [row.id], [row.id])
1127
1251
  const [target, setTarget] = React.useState('')
1128
- const targets = (state?.workspaces ?? []).filter((workspace) => workspace.id !== row.workspaceId && workspace.status === 'ok')
1252
+ const targets = (overview?.workspaces ?? []).filter((workspace) => workspace.id !== row.workspaceId && workspace.status === 'ok')
1129
1253
  const movable = row.can?.move === true && targets.length > 0
1130
1254
  const uiWorkspace = (() => {
1131
1255
  try {
@@ -1272,6 +1396,7 @@ window.__ModuleLoader__.load({
1272
1396
  ctx,
1273
1397
  t,
1274
1398
  ids: deleteIds,
1399
+ maxBatch: limits?.maxBatch ?? 50,
1275
1400
  busy,
1276
1401
  onCancel: () => setShowDelete(false),
1277
1402
  onConfirmed: (ids) => onDelete(ids),
@@ -1317,16 +1442,11 @@ window.__ModuleLoader__.load({
1317
1442
  return out.length === rows.length ? out : rows
1318
1443
  }
1319
1444
 
1320
- /**
1321
- * Derive the filtered / grouped / rendered view for the list.
1322
- * Pure so the selection-pruning effect can use it before the loading guard.
1323
- */
1324
- function computeView({ state, tab, workspaceFilter, query, sort, expanded, defaultExpanded, limits, showBlank }) {
1325
- const rows = state?.sessions ?? []
1326
- const workspaceOrder = state?.workspaces ?? []
1327
- const text = query.trim().toLowerCase()
1445
+ /** Tab / workspace / blank / text filter over one state's rows. */
1446
+ function filterRows(rows, { tab, workspaceFilter, query, showBlank }) {
1447
+ const text = (query ?? '').trim().toLowerCase()
1328
1448
  const filtering = text !== ''
1329
- const filtered = rows.filter((row) => {
1449
+ return rows.filter((row) => {
1330
1450
  if (tab === 'active' && row.archived) return false
1331
1451
  if (tab === 'archived' && !row.archived) return false
1332
1452
  if (workspaceFilter === 'ungrouped' && row.workspaceId !== null) return false
@@ -1338,20 +1458,37 @@ window.__ModuleLoader__.load({
1338
1458
  }
1339
1459
  return true
1340
1460
  })
1341
- const sorted = [...filtered].sort((left, right) => {
1342
- if (sort === 'created') return right.createdAt - left.createdAt
1343
- if (sort === 'size') return right.sizeBytes - left.sizeBytes
1344
- if (sort === 'title') return (left.title || left.cwd).localeCompare(right.title || right.cwd)
1345
- return right.updatedAt - left.updatedAt
1461
+ }
1462
+
1463
+ /** Id order used as the tie-break of every sort so paged slices stay stable. */
1464
+ function compareRowIds(left, right) {
1465
+ if (left.id === right.id) return 0
1466
+ return left.id < right.id ? -1 : 1
1467
+ }
1468
+
1469
+ /** Sort a filtered row list; equal keys fall back to id ascending. */
1470
+ function sortRows(rows, sort) {
1471
+ return [...rows].sort((left, right) => {
1472
+ let order
1473
+ if (sort === 'created') order = right.createdAt - left.createdAt
1474
+ else if (sort === 'size') order = right.sizeBytes - left.sizeBytes
1475
+ else if (sort === 'title') order = (left.title || left.cwd).localeCompare(right.title || right.cwd)
1476
+ else order = right.updatedAt - left.updatedAt
1477
+ // `!order` also catches NaN from a missing numeric field.
1478
+ return !order ? compareRowIds(left, right) : order
1346
1479
  })
1480
+ }
1481
+
1482
+ /** Group sorted rows in workspace order, keeping the ungrouped group last. */
1483
+ function groupRows(sorted, workspaceOrder) {
1347
1484
  const baseGroups = []
1348
1485
  for (const workspace of workspaceOrder) {
1349
- const groupRows = orderBranchRows(sorted.filter((row) => row.workspaceId === workspace.id))
1350
- if (groupRows.length === 0) continue
1486
+ const rows = orderBranchRows(sorted.filter((row) => row.workspaceId === workspace.id))
1487
+ if (rows.length === 0) continue
1351
1488
  baseGroups.push({
1352
1489
  ...workspace,
1353
- rows: groupRows,
1354
- bytes: groupRows.reduce((sum, row) => sum + row.sizeBytes, 0),
1490
+ rows,
1491
+ bytes: rows.reduce((sum, row) => sum + row.sizeBytes, 0),
1355
1492
  })
1356
1493
  }
1357
1494
  const ungroupedRows = orderBranchRows(sorted.filter((row) => row.workspaceId === null))
@@ -1364,7 +1501,12 @@ window.__ModuleLoader__.load({
1364
1501
  bytes: ungroupedRows.reduce((sum, row) => sum + row.sizeBytes, 0),
1365
1502
  })
1366
1503
  }
1367
- const groups = baseGroups.map((group) => {
1504
+ return baseGroups
1505
+ }
1506
+
1507
+ /** Attach expansion + page-limit state to each group; only expanded groups render rows. */
1508
+ function expandGroups(baseGroups, { expanded, defaultExpanded, limits, filtering }) {
1509
+ return baseGroups.map((group) => {
1368
1510
  const isExpanded = filtering ? true : (expanded === null ? defaultExpanded : expanded.has(group.id))
1369
1511
  const limit = limits[group.id] ?? GROUP_PAGE_SIZE
1370
1512
  return {
@@ -1374,6 +1516,47 @@ window.__ModuleLoader__.load({
1374
1516
  renderedRows: isExpanded ? group.rows.slice(0, limit) : [],
1375
1517
  }
1376
1518
  })
1519
+ }
1520
+
1521
+ /**
1522
+ * Keep only the selected ids that are still visible. Returning the previous
1523
+ * Set when nothing is dropped keeps the React state identity stable.
1524
+ */
1525
+ function pruneSelection(selected, visibleIdSet) {
1526
+ if (selected.size === 0) return selected
1527
+ const next = new Set()
1528
+ for (const id of selected) if (visibleIdSet.has(id)) next.add(id)
1529
+ return next.size === selected.size ? selected : next
1530
+ }
1531
+
1532
+ /**
1533
+ * Key of every scope-dependent client state (selection pruning, page
1534
+ * limits): it must change whenever a filter *or the sort* changes.
1535
+ */
1536
+ function scopeKey({ generatedAt, tab, workspaceFilter, query, sort, includeSubagents, showBlank, defaultExpanded, expanded }) {
1537
+ return [
1538
+ generatedAt ?? 0,
1539
+ tab,
1540
+ workspaceFilter,
1541
+ query,
1542
+ sort,
1543
+ includeSubagents ? 1 : 0,
1544
+ showBlank ? 1 : 0,
1545
+ defaultExpanded ? 1 : 0,
1546
+ expanded === null || expanded === undefined ? 'auto' : [...expanded].sort().join(','),
1547
+ ].join('|')
1548
+ }
1549
+
1550
+ /**
1551
+ * Derive the filtered / grouped / rendered view for the list.
1552
+ * Pure so the selection-pruning effect can use it before the loading guard.
1553
+ */
1554
+ function computeView({ state, tab, workspaceFilter, query, sort, expanded, defaultExpanded, limits, showBlank }) {
1555
+ const rows = state?.sessions ?? []
1556
+ const workspaceOrder = state?.workspaces ?? []
1557
+ const filtering = query.trim() !== ''
1558
+ const sorted = sortRows(filterRows(rows, { tab, workspaceFilter, query, showBlank }), sort)
1559
+ const groups = expandGroups(groupRows(sorted, workspaceOrder), { expanded, defaultExpanded, limits, filtering })
1377
1560
  const visibleRows = groups.flatMap((group) => (group.expanded ? group.rows : []))
1378
1561
  return {
1379
1562
  filteredRows: sorted,
@@ -1383,87 +1566,691 @@ window.__ModuleLoader__.load({
1383
1566
  }
1384
1567
  }
1385
1568
 
1569
+ // ---------------------------------------------------------------------
1570
+ // Paged data layer helpers (pure; the page only schedules requests)
1571
+ // ---------------------------------------------------------------------
1572
+
1573
+ /** True when a request failure means this Host build has no such route. */
1574
+ function isMissingRoute(failure) {
1575
+ return failure?.status === 404 || failure?.code === 'http-404' || failure?.code === 'not-found'
1576
+ }
1577
+
1578
+ /** Encode a flat parameter object as a query string, skipping empty values. */
1579
+ function queryOf(params) {
1580
+ const parts = []
1581
+ for (const key of Object.keys(params ?? {})) {
1582
+ const value = params[key]
1583
+ if (value === undefined || value === null || value === '') continue
1584
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
1585
+ }
1586
+ return parts.join('&')
1587
+ }
1588
+
1589
+ /** The six-field scope key that invalidates page caches and selection. */
1590
+ function dataScopeKey({ tab, workspaceFilter, query, sort, includeSubagents, showBlank }) {
1591
+ return [tab, workspaceFilter, query, sort, includeSubagents ? 1 : 0, showBlank ? 1 : 0].join('|')
1592
+ }
1593
+
1594
+ /** Append one page to the loaded rows, skipping ids already on screen. */
1595
+ function mergePageRows(current, incoming) {
1596
+ if (!Array.isArray(incoming) || incoming.length === 0) return current
1597
+ const seen = new Set(current.map((row) => row.id))
1598
+ const out = [...current]
1599
+ for (const row of incoming) {
1600
+ if (seen.has(row.id)) continue
1601
+ seen.add(row.id)
1602
+ out.push(row)
1603
+ }
1604
+ return out
1605
+ }
1606
+
1607
+ /** Merge rows into the id → row cache; later responses win. */
1608
+ function mergeRowCache(cache, rows) {
1609
+ if (!Array.isArray(rows) || rows.length === 0) return cache
1610
+ const next = new Map(cache)
1611
+ for (const row of rows) next.set(row.id, row)
1612
+ return next
1613
+ }
1614
+
1615
+ /**
1616
+ * Nest one group's loaded rows and annotate branch rows whose parent is not
1617
+ * among them: such a row stays top level but keeps the "↳" glyph, and its
1618
+ * meta line distinguishes "not loaded", "no longer exists", and "from X".
1619
+ */
1620
+ function annotateBranchRows(rows) {
1621
+ const ordered = orderBranchRows(rows)
1622
+ const ids = new Set(rows.map((row) => row.id))
1623
+ return ordered.map((row) => {
1624
+ if (row.kind !== 'fork') return row
1625
+ const parentLoaded = row.parentSessionId !== null && ids.has(row.parentSessionId)
1626
+ if (parentLoaded) {
1627
+ return { ...row, branchParentTitle: row.parentTitle || '', branchParentMissing: false, branchParentNotLoaded: false }
1628
+ }
1629
+ const stub = row.parentStub
1630
+ const stubKnown = stub !== null && stub !== undefined
1631
+ const present = stubKnown ? stub.present === true : row.parentPresent !== false
1632
+ const stubTitle = stubKnown && typeof stub.title === 'string' ? stub.title : ''
1633
+ const title = stubTitle !== '' ? stubTitle : row.parentTitle || ''
1634
+ if (present !== true) {
1635
+ return { ...row, branchDepth: 0, branchOrphan: true, branchParentTitle: '', branchParentMissing: true, branchParentNotLoaded: false }
1636
+ }
1637
+ if (title !== '') {
1638
+ return { ...row, branchDepth: 0, branchOrphan: true, branchParentTitle: title, branchParentMissing: false, branchParentNotLoaded: false }
1639
+ }
1640
+ return { ...row, branchDepth: 0, branchOrphan: true, branchParentTitle: '', branchParentMissing: false, branchParentNotLoaded: true }
1641
+ })
1642
+ }
1643
+
1644
+ /** A `/state` payload expressed in the `/overview` shape (old-Host fallback). */
1645
+ function overviewFromState(state) {
1646
+ const rows = Array.isArray(state?.sessions) ? state.sessions : []
1647
+ const workspaces = Array.isArray(state?.workspaces) ? state.workspaces : []
1648
+ const bucketOf = (subset) => ({
1649
+ count: subset.length,
1650
+ activeCount: subset.filter((row) => !row.archived).length,
1651
+ archivedCount: subset.filter((row) => row.archived).length,
1652
+ blankCount: subset.filter((row) => row.blank === true).length,
1653
+ })
1654
+ const totals = state?.totals ?? {}
1655
+ const ungroupedRows = rows.filter((row) => row.workspaceId === null)
1656
+ return {
1657
+ generatedAt: state?.generatedAt ?? 0,
1658
+ source: 'legacy',
1659
+ backend: state?.backend,
1660
+ capabilities: state?.capabilities ?? {},
1661
+ limits: state?.limits ?? {},
1662
+ workspaces: workspaces.map((workspace) => ({
1663
+ ...workspace,
1664
+ ...bucketOf(rows.filter((row) => row.workspaceId === workspace.id)),
1665
+ totalBytes: null,
1666
+ })),
1667
+ ungrouped: { ...bucketOf(ungroupedRows), totalBytes: null },
1668
+ totals: {
1669
+ all: totals.all ?? rows.length,
1670
+ active: totals.active ?? rows.filter((row) => !row.archived).length,
1671
+ archived: totals.archived ?? rows.filter((row) => row.archived).length,
1672
+ subagents: rows.filter((row) => row.isSubagent === true).length,
1673
+ hiddenSubagents: totals.hiddenSubagents ?? 0,
1674
+ forks: rows.filter((row) => row.kind === 'fork').length,
1675
+ blank: rows.filter((row) => row.blank === true).length,
1676
+ bytes: totals.bytes ?? null,
1677
+ },
1678
+ orphans: Array.isArray(state?.orphans) ? state.orphans : [],
1679
+ }
1680
+ }
1681
+
1682
+ /** The three blank-session id lists, derived from one `/state` payload. */
1683
+ function blanksFromState(state) {
1684
+ const rows = Array.isArray(state?.sessions) ? state.sessions : []
1685
+ const blank = rows.filter((row) => row.blank === true)
1686
+ return {
1687
+ unarchivedIds: blank.filter((row) => !row.archived).map((row) => row.id),
1688
+ archivedIds: blank.filter((row) => row.archived).map((row) => row.id),
1689
+ deletableIds: blank.filter((row) => row.archived && row.can?.delete === true).map((row) => row.id),
1690
+ }
1691
+ }
1692
+
1693
+ function bytesOf(value) {
1694
+ if (value === null || value === undefined) return null
1695
+ const number = Number(value)
1696
+ return Number.isFinite(number) ? number : null
1697
+ }
1698
+
1699
+ /**
1700
+ * Normalize the `/sizes` payload:
1701
+ * `{total:{bytes,count}, workspaces:[{id,bytes,count}], ungrouped:{bytes,count}}`.
1702
+ * The short aliases keep a degraded or older payload usable.
1703
+ */
1704
+ function normalizeSizes(value) {
1705
+ const byWorkspace = new Map()
1706
+ const list = Array.isArray(value) ? value : Array.isArray(value?.workspaces) ? value.workspaces : []
1707
+ for (const item of list) {
1708
+ if (item === null || typeof item !== 'object') continue
1709
+ const id = typeof item.id === 'string' ? item.id : typeof item.workspaceId === 'string' ? item.workspaceId : ''
1710
+ if (id === '') continue
1711
+ const bytes = bytesOf(item.totalBytes ?? item.bytes ?? item.sizeBytes)
1712
+ if (bytes !== null) byWorkspace.set(id, bytes)
1713
+ }
1714
+ const ungroupedBytes = bytesOf(value?.ungrouped?.bytes ?? value?.ungrouped?.totalBytes)
1715
+ if (ungroupedBytes !== null) byWorkspace.set('ungrouped', ungroupedBytes)
1716
+ const totalSource = value?.totalBytes ?? value?.bytes ?? value?.total
1717
+ const totalBytes = totalSource !== null && typeof totalSource === 'object'
1718
+ ? bytesOf(totalSource.bytes ?? totalSource.totalBytes ?? totalSource.sizeBytes)
1719
+ : bytesOf(totalSource)
1720
+ return { totalBytes, byWorkspace }
1721
+ }
1722
+
1723
+ function formatBytesOrDash(bytes) {
1724
+ return Number.isFinite(bytes) && bytes >= 0 ? formatBytes(bytes) : '—'
1725
+ }
1726
+
1727
+ /** Count one overview bucket for a tab. */
1728
+ function countBucket(bucket, tab) {
1729
+ if (bucket === null || bucket === undefined) return 0
1730
+ if (tab === 'archived') return bucket.archivedCount ?? 0
1731
+ if (tab === 'active') return bucket.activeCount ?? 0
1732
+ return bucket.count ?? 0
1733
+ }
1734
+
1735
+ /**
1736
+ * Compose the rendered groups from the overview plus the loaded page cache.
1737
+ * Pure: the page component only memoizes it. `searchMode` keeps only groups
1738
+ * the search answered for; `requirePage` does the same for the legacy
1739
+ * fallback, whose pages are built locally.
1740
+ */
1741
+ function composeGroups({ overview, pages, sizes, expanded, defaultExpanded, query, tab, workspaceFilter, showBlank, searchMode, requirePage, legacy, legacyLimits }) {
1742
+ const filtering = typeof query === 'string' && query.trim() !== ''
1743
+ const isExpanded = (id) => (filtering ? true : expanded === null ? defaultExpanded : expanded.has(id))
1744
+ const workspaces = Array.isArray(overview?.workspaces) ? overview.workspaces : []
1745
+ const workspaceById = new Map(workspaces.map((workspace) => [workspace.id, workspace]))
1746
+ const ids = []
1747
+ for (const workspace of workspaces) {
1748
+ if (workspaceFilter !== 'all' && workspace.id !== workspaceFilter) continue
1749
+ ids.push(workspace.id)
1750
+ }
1751
+ const ungrouped = overview?.ungrouped
1752
+ if ((workspaceFilter === 'all' || workspaceFilter === 'ungrouped') && (ungrouped?.count ?? 0) > 0) ids.push('ungrouped')
1753
+ const groups = []
1754
+ for (const id of ids) {
1755
+ // Structural checks: these maps may come from another realm in tests.
1756
+ const page = pages !== null && pages !== undefined && typeof pages.get === 'function' ? pages.get(id) : undefined
1757
+ if ((searchMode === true || requirePage === true) && page === undefined) continue
1758
+ const meta = id === 'ungrouped'
1759
+ ? { title: '', path: '', status: 'ok' }
1760
+ : workspaceById.get(id) ?? { title: '', path: '', status: 'ok' }
1761
+ let fallbackCount = id === 'ungrouped' ? countBucket(ungrouped, tab) : countBucket(meta, tab)
1762
+ if (showBlank !== true && tab === 'all') {
1763
+ fallbackCount -= id === 'ungrouped' ? ungrouped?.blankCount ?? 0 : meta.blankCount ?? 0
1764
+ }
1765
+ if (fallbackCount < 0) fallbackCount = 0
1766
+ if (page === undefined && fallbackCount <= 0) continue
1767
+ if (page !== undefined && page.phase === 'ready' && (page.total ?? 0) === 0) continue
1768
+ const rows = annotateBranchRows(Array.isArray(page?.rows) ? page.rows : [])
1769
+ // The legacy fallback has every row in memory but keeps the 0.1.x
1770
+ // per-group window: expand to 50, "load more" grows it by 50.
1771
+ const windowSize = legacy === true ? legacyLimits?.[id] ?? GROUP_PAGE_SIZE : rows.length
1772
+ groups.push({
1773
+ id,
1774
+ title: meta.title ?? '',
1775
+ path: meta.path ?? '',
1776
+ status: meta.status,
1777
+ rows,
1778
+ renderedRows: isExpanded(id) ? rows.slice(0, windowSize) : [],
1779
+ expanded: isExpanded(id),
1780
+ total: page !== undefined && Number.isFinite(page.total) ? page.total : fallbackCount,
1781
+ hasMore: page?.hasMore === true,
1782
+ phase: page?.phase ?? 'idle',
1783
+ error: page?.error ?? null,
1784
+ bytes: sizes !== null && sizes !== undefined && typeof sizes.byWorkspace?.get === 'function' ? sizes.byWorkspace.get(id) ?? null : null,
1785
+ })
1786
+ }
1787
+ const visibleRows = groups.flatMap((group) => (group.expanded === true ? group.rows : []))
1788
+ let partialCount = 0
1789
+ for (const group of groups) {
1790
+ if (group.expanded !== true) continue
1791
+ partialCount += Math.max(0, (group.total ?? 0) - group.rows.length)
1792
+ }
1793
+ return {
1794
+ groups,
1795
+ visibleRows,
1796
+ visibleIdSet: new Set(visibleRows.map((row) => row.id)),
1797
+ partialCount,
1798
+ }
1799
+ }
1800
+
1801
+ /** Run scheduled page loads with a small concurrency ceiling. */
1802
+ function pumpLimiter(state) {
1803
+ while (state.active < MAX_CONCURRENT_PAGES && state.queue.length > 0) {
1804
+ const task = state.queue.shift()
1805
+ state.active += 1
1806
+ Promise.resolve()
1807
+ .then(task)
1808
+ .catch(() => {})
1809
+ .then(() => {
1810
+ state.active -= 1
1811
+ pumpLimiter(state)
1812
+ })
1813
+ }
1814
+ }
1815
+
1386
1816
  function SessionManagerPage(props) {
1387
1817
  const { ctx, t } = props
1388
1818
  useLocaleTick(ctx)
1389
- const [state, setState] = React.useState(null)
1390
- const [phase, setPhase] = React.useState('loading')
1391
- const [error, setError] = React.useState(null)
1392
1819
  const [tab, setTab] = React.useState('all')
1393
1820
  const [workspaceFilter, setWorkspaceFilter] = React.useState('all')
1821
+ const [queryInput, setQueryInput] = React.useState('')
1394
1822
  const [query, setQuery] = React.useState('')
1395
1823
  const [sort, setSort] = React.useState('updated')
1396
1824
  const [includeSubagents, setIncludeSubagents] = React.useState(false)
1825
+ const [showBlank, setShowBlank] = React.useState(false)
1826
+ const [expanded, setExpanded] = React.useState(null)
1397
1827
  const [selected, setSelected] = React.useState(() => new Set())
1398
1828
  const [selectedId, setSelectedId] = React.useState(null)
1399
1829
  const [busy, setBusy] = React.useState(false)
1400
1830
  const [notice, setNotice] = React.useState(null)
1401
1831
  const [bulkDeleteIds, setBulkDeleteIds] = React.useState(null)
1402
1832
  const [bulkTarget, setBulkTarget] = React.useState('')
1403
- const [expanded, setExpanded] = React.useState(null)
1404
- const [limits, setLimits] = React.useState({})
1405
- const [showBlank, setShowBlank] = React.useState(false)
1406
- const abortRef = React.useRef(null)
1833
+ // Two-stage data layer: `overview` is the first paint, `pages` holds one
1834
+ // lazily loaded page box per workspace, `rowCache` is the only row source
1835
+ // for the detail view and for batch submissions.
1836
+ const [transport, setTransport] = React.useState('loading')
1837
+ const [overview, setOverview] = React.useState(null)
1838
+ const [overviewPhase, setOverviewPhase] = React.useState('loading')
1839
+ const [overviewError, setOverviewError] = React.useState(null)
1840
+ const [legacyState, setLegacyState] = React.useState(null)
1841
+ const [pages, setPages] = React.useState(() => new Map())
1842
+ const [rowCache, setRowCache] = React.useState(() => new Map())
1843
+ const [blanks, setBlanks] = React.useState(null)
1844
+ const [sizes, setSizes] = React.useState(null)
1845
+ const [sizesPhase, setSizesPhase] = React.useState('idle')
1846
+ const [searchPhase, setSearchPhase] = React.useState('idle')
1847
+ const [searchError, setSearchError] = React.useState(null)
1848
+ const [reload, setReload] = React.useState(0)
1849
+ // Legacy fallback only: per-group row window (0.1.x "load more").
1850
+ const [legacyLimits, setLegacyLimits] = React.useState({})
1851
+ const [detail, setDetail] = React.useState(null)
1852
+ const [detailPhase, setDetailPhase] = React.useState('idle')
1853
+ const [detailError, setDetailError] = React.useState(null)
1854
+ const [detailNonce, setDetailNonce] = React.useState(0)
1407
1855
  const searchRef = React.useRef(null)
1408
1856
  const listScroll = React.useRef(0)
1857
+ const overviewAbort = React.useRef(null)
1858
+ const sizesAbort = React.useRef(null)
1859
+ const searchAbort = React.useRef(null)
1860
+ const detailAbort = React.useRef(null)
1861
+ const pagesEpoch = React.useRef(0)
1862
+ const pageControllers = React.useRef(new Map())
1863
+ const inflightPages = React.useRef(new Set())
1864
+ const limiter = React.useRef({ active: 0, queue: [] })
1865
+ const preserveCounts = React.useRef(new Map())
1866
+ const preserveChain = React.useRef(new Set())
1867
+ const transportRef = React.useRef('loading')
1868
+ const overviewRef = React.useRef(null)
1869
+ const legacyStateRef = React.useRef(null)
1870
+ const pagesRef = React.useRef(pages)
1871
+ const rowCacheRef = React.useRef(rowCache)
1872
+ const tabRef = React.useRef(tab)
1873
+ const sortRef = React.useRef(sort)
1874
+ const queryRef = React.useRef(query)
1875
+ const includeSubagentsRef = React.useRef(includeSubagents)
1876
+ const showBlankRef = React.useRef(showBlank)
1877
+ // Latest-value refs: background tasks read these instead of stale closures.
1878
+ pagesRef.current = pages
1879
+ rowCacheRef.current = rowCache
1880
+ tabRef.current = tab
1881
+ sortRef.current = sort
1882
+ queryRef.current = query
1883
+ includeSubagentsRef.current = includeSubagents
1884
+ showBlankRef.current = showBlank
1885
+
1886
+ const scope = dataScopeKey({ tab, workspaceFilter, query, sort, includeSubagents, showBlank })
1887
+ const isSearch = query !== ''
1888
+
1889
+ /** The two empty-session shortcuts' id lists; absent until a response lands. */
1890
+ const loadBlanks = async () => {
1891
+ try {
1892
+ const value = await getBlanks(includeSubagentsRef.current)
1893
+ if (value !== null && value !== undefined) setBlanks(value)
1894
+ } catch {
1895
+ // The shortcuts degrade to hidden buttons when /blanks is unavailable.
1896
+ setBlanks(null)
1897
+ }
1898
+ }
1409
1899
 
1410
- const load = React.useCallback(async (options = {}) => {
1411
- abortRef.current?.abort()
1900
+ /** Total / per-workspace bytes, fetched out of band and never awaited. */
1901
+ const loadSizes = async () => {
1902
+ sizesAbort.current?.abort()
1412
1903
  const controller = new AbortController()
1413
- abortRef.current = controller
1414
- if (options.silent !== true) setPhase((previous) => (previous === 'ready' ? previous : 'loading'))
1904
+ sizesAbort.current = controller
1905
+ setSizesPhase('loading')
1415
1906
  try {
1416
- const value = await getState(includeSubagents, controller.signal)
1907
+ const value = await getSizes('all', includeSubagentsRef.current, controller.signal)
1417
1908
  if (controller.signal.aborted) return
1418
- setState(value)
1419
- setPhase('ready')
1420
- setError(null)
1909
+ setSizes(normalizeSizes(value))
1910
+ setSizesPhase('ready')
1421
1911
  } catch (failure) {
1422
1912
  if (failure?.name === 'AbortError') return
1423
- setError(failure)
1424
- setPhase('error')
1913
+ setSizes(null)
1914
+ setSizesPhase('error')
1425
1915
  }
1426
- }, [includeSubagents])
1916
+ }
1917
+
1918
+ /**
1919
+ * First paint: `/overview` (counts only). A Host that predates the route
1920
+ * is detected once and served by the 0.1.x `/state` path from then on.
1921
+ */
1922
+ const loadOverview = async (options = {}) => {
1923
+ overviewAbort.current?.abort()
1924
+ const controller = new AbortController()
1925
+ overviewAbort.current = controller
1926
+ const silent = options.silent === true
1927
+ if (!silent && overviewRef.current === null) setOverviewPhase('loading')
1928
+ try {
1929
+ if (transportRef.current !== 'legacy') {
1930
+ try {
1931
+ const value = await getOverview(includeSubagentsRef.current, controller.signal)
1932
+ if (controller.signal.aborted) return
1933
+ transportRef.current = 'paged'
1934
+ setTransport('paged')
1935
+ overviewRef.current = value
1936
+ setOverview(value)
1937
+ setOverviewPhase('ready')
1938
+ setOverviewError(null)
1939
+ void loadBlanks()
1940
+ return
1941
+ } catch (failure) {
1942
+ if (failure?.name === 'AbortError') return
1943
+ if (transportRef.current !== 'loading' || !isMissingRoute(failure)) throw failure
1944
+ // Old Host: no /overview route at all. Fall back for good.
1945
+ transportRef.current = 'legacy'
1946
+ setTransport('legacy')
1947
+ }
1948
+ }
1949
+ const value = await getState(includeSubagentsRef.current, controller.signal)
1950
+ if (controller.signal.aborted) return
1951
+ legacyStateRef.current = value
1952
+ setLegacyState(value)
1953
+ setOverview(overviewFromState(value))
1954
+ setBlanks(blanksFromState(value))
1955
+ setOverviewPhase('ready')
1956
+ setOverviewError(null)
1957
+ } catch (failure) {
1958
+ if (failure?.name === 'AbortError') return
1959
+ setOverviewError(failure)
1960
+ setOverviewPhase('error')
1961
+ }
1962
+ }
1963
+
1964
+ /** Cross-workspace search: one request answers every matching workspace. */
1965
+ const runSearch = async () => {
1966
+ searchAbort.current?.abort()
1967
+ const controller = new AbortController()
1968
+ searchAbort.current = controller
1969
+ const epoch = pagesEpoch.current
1970
+ setSearchPhase('loading')
1971
+ setSearchError(null)
1972
+ try {
1973
+ const value = await getSearch({
1974
+ query: queryRef.current,
1975
+ tab: tabRef.current,
1976
+ sort: sortRef.current,
1977
+ subagents: includeSubagentsRef.current ? 1 : 0,
1978
+ blank: showBlankRef.current ? 1 : 0,
1979
+ limitPerGroup: GROUP_PAGE_SIZE,
1980
+ }, controller.signal)
1981
+ if (controller.signal.aborted || epoch !== pagesEpoch.current) return
1982
+ const groups = Array.isArray(value?.groups) ? value.groups : []
1983
+ const nextPages = new Map()
1984
+ const cacheRows = []
1985
+ for (const group of groups) {
1986
+ const id = group?.workspaceId === null || group?.workspaceId === undefined ? 'ungrouped' : group.workspaceId
1987
+ const rows = Array.isArray(group?.rows) ? group.rows : []
1988
+ nextPages.set(id, {
1989
+ rows,
1990
+ total: Number.isFinite(group?.total) ? group.total : rows.length,
1991
+ offset: rows.length,
1992
+ hasMore: group?.hasMore === true,
1993
+ phase: 'ready',
1994
+ error: null,
1995
+ })
1996
+ for (const row of rows) cacheRows.push(row)
1997
+ }
1998
+ pagesRef.current = nextPages
1999
+ setPages(nextPages)
2000
+ setRowCache((previous) => mergeRowCache(previous, cacheRows))
2001
+ setSearchPhase('ready')
2002
+ } catch (failure) {
2003
+ if (failure?.name === 'AbortError') return
2004
+ if (epoch !== pagesEpoch.current) return
2005
+ setSearchError(failure)
2006
+ setSearchPhase('error')
2007
+ }
2008
+ }
2009
+
2010
+ /** Load (or append) one workspace page; stale epochs are dropped. */
2011
+ const loadGroupPage = async (groupId, requestOffset, options = {}) => {
2012
+ if (transportRef.current !== 'paged') return
2013
+ const append = options.append === true
2014
+ const epoch = pagesEpoch.current
2015
+ const controller = new AbortController()
2016
+ pageControllers.current.get(groupId)?.abort()
2017
+ pageControllers.current.set(groupId, controller)
2018
+ setPages((previous) => {
2019
+ const next = new Map(previous)
2020
+ const page = next.get(groupId) ?? { rows: [], total: 0, offset: 0, hasMore: false }
2021
+ next.set(groupId, { ...page, phase: 'loading', error: null })
2022
+ return next
2023
+ })
2024
+ try {
2025
+ const value = await getSessions({
2026
+ workspaceId: groupId,
2027
+ tab: tabRef.current,
2028
+ query: queryRef.current,
2029
+ sort: sortRef.current,
2030
+ subagents: includeSubagentsRef.current ? 1 : 0,
2031
+ blank: showBlankRef.current ? 1 : 0,
2032
+ offset: requestOffset,
2033
+ limit: GROUP_PAGE_SIZE,
2034
+ sizes: 1,
2035
+ }, controller.signal)
2036
+ if (controller.signal.aborted || epoch !== pagesEpoch.current) return
2037
+ const incoming = Array.isArray(value?.rows) ? value.rows : []
2038
+ setPages((previous) => {
2039
+ const next = new Map(previous)
2040
+ const page = next.get(groupId) ?? { rows: [], total: 0, offset: 0, hasMore: false }
2041
+ const rows = append ? mergePageRows(page.rows, incoming) : incoming
2042
+ next.set(groupId, {
2043
+ rows,
2044
+ total: Number.isFinite(value?.total) ? value.total : rows.length,
2045
+ offset: (Number.isFinite(requestOffset) ? requestOffset : 0) + incoming.length,
2046
+ hasMore: value?.hasMore === true,
2047
+ phase: 'ready',
2048
+ error: null,
2049
+ })
2050
+ return next
2051
+ })
2052
+ setRowCache((previous) => mergeRowCache(previous, incoming))
2053
+ // Walk back down to the depth this group had loaded before a refresh
2054
+ // (writes and the Refresh button preserve selection/scroll).
2055
+ const target = preserveCounts.current.get(groupId)
2056
+ if (target !== undefined) {
2057
+ const loaded = (Number.isFinite(requestOffset) ? requestOffset : 0) + incoming.length
2058
+ if (loaded < target && value?.hasMore === true) {
2059
+ if (!preserveChain.current.has(groupId)) {
2060
+ preserveChain.current.add(groupId)
2061
+ schedulePage(async () => {
2062
+ try {
2063
+ await loadGroupPage(groupId, loaded, { append: true })
2064
+ } finally {
2065
+ preserveChain.current.delete(groupId)
2066
+ }
2067
+ })
2068
+ }
2069
+ } else {
2070
+ preserveCounts.current.delete(groupId)
2071
+ }
2072
+ }
2073
+ } catch (failure) {
2074
+ if (failure?.name === 'AbortError') return
2075
+ if (epoch !== pagesEpoch.current) return
2076
+ setPages((previous) => {
2077
+ const next = new Map(previous)
2078
+ const page = next.get(groupId) ?? { rows: [], total: 0, offset: 0, hasMore: false }
2079
+ next.set(groupId, { ...page, phase: 'error', error: failure })
2080
+ return next
2081
+ })
2082
+ } finally {
2083
+ if (pageControllers.current.get(groupId) === controller) pageControllers.current.delete(groupId)
2084
+ }
2085
+ }
2086
+
2087
+ /** Queue one page load under the shared concurrency ceiling. */
2088
+ const schedulePage = (task) => {
2089
+ const state = limiter.current
2090
+ state.queue.push(task)
2091
+ pumpLimiter(state)
2092
+ }
2093
+
2094
+ const ensureGroupPage = (groupId, force = false) => {
2095
+ if (transportRef.current !== 'paged') return
2096
+ if (force !== true && pagesRef.current.has(groupId)) return
2097
+ if (inflightPages.current.has(groupId)) return
2098
+ inflightPages.current.add(groupId)
2099
+ schedulePage(async () => {
2100
+ try {
2101
+ await loadGroupPage(groupId, 0)
2102
+ } finally {
2103
+ inflightPages.current.delete(groupId)
2104
+ }
2105
+ })
2106
+ }
2107
+
2108
+ const loadMoreGroup = (groupId) => {
2109
+ if (transportRef.current !== 'paged') return
2110
+ if (inflightPages.current.has(groupId)) return
2111
+ const page = pagesRef.current.get(groupId)
2112
+ const offset = page?.rows?.length ?? 0
2113
+ inflightPages.current.add(groupId)
2114
+ schedulePage(async () => {
2115
+ try {
2116
+ await loadGroupPage(groupId, offset, { append: true })
2117
+ } finally {
2118
+ inflightPages.current.delete(groupId)
2119
+ }
2120
+ })
2121
+ }
2122
+
2123
+ const retryGroup = (groupId) => {
2124
+ ensureGroupPage(groupId, true)
2125
+ }
2126
+
2127
+ /** Drop every loaded page (scope change or an explicit refresh). */
2128
+ const resetPageCache = (clearSelection) => {
2129
+ pagesEpoch.current += 1
2130
+ for (const controller of pageControllers.current.values()) controller.abort()
2131
+ pageControllers.current.clear()
2132
+ inflightPages.current.clear()
2133
+ preserveChain.current = new Set()
2134
+ if (clearSelection === true) preserveCounts.current = new Map()
2135
+ setLegacyLimits({})
2136
+ pagesRef.current = new Map()
2137
+ setPages(new Map())
2138
+ if (clearSelection === true) setSelected(new Set())
2139
+ setSearchPhase('idle')
2140
+ setSearchError(null)
2141
+ }
2142
+
2143
+ /** Refresh after a write or the Refresh button: overview first, then pages. */
2144
+ const refresh = async () => {
2145
+ // Remember how deep each loaded group was so the refetch keeps the
2146
+ // user's scroll position instead of collapsing back to one page.
2147
+ const counts = new Map()
2148
+ for (const [groupId, page] of pagesRef.current) {
2149
+ if (page?.phase === 'ready' && (page.rows?.length ?? 0) > 0) counts.set(groupId, page.rows.length)
2150
+ }
2151
+ preserveCounts.current = counts
2152
+ await loadOverview({ silent: true })
2153
+ setReload((value) => value + 1)
2154
+ setDetailNonce((value) => value + 1)
2155
+ }
1427
2156
 
1428
2157
  React.useEffect(() => {
1429
- void load()
1430
- return () => abortRef.current?.abort()
1431
- }, [load])
2158
+ void loadOverview()
2159
+ return () => {
2160
+ overviewAbort.current?.abort()
2161
+ for (const controller of pageControllers.current.values()) controller.abort()
2162
+ }
2163
+ }, [includeSubagents])
1432
2164
 
2165
+ // The window gaining focus only refreshes the cheap first paint; open
2166
+ // workspaces stay as they are and reload on the next explicit action.
1433
2167
  React.useEffect(() => {
1434
- const onFocus = () => void load({ silent: true })
2168
+ const onFocus = () => void loadOverview({ silent: true })
1435
2169
  window.addEventListener('focus', onFocus)
1436
2170
  return () => window.removeEventListener('focus', onFocus)
1437
- }, [load])
2171
+ }, [])
1438
2172
 
2173
+ // A scope change invalidates every page AND the selection (data safety:
2174
+ // an id selected under another tab/query must never reach a batch call).
1439
2175
  React.useEffect(() => {
1440
- if (selectedId === null) return
1441
- if (state !== null && !state.sessions.some((row) => row.id === selectedId)) setSelectedId(null)
1442
- }, [state, selectedId])
2176
+ resetPageCache(true)
2177
+ }, [scope])
2178
+
2179
+ // A write or manual refresh drops the loaded pages but keeps the selection.
2180
+ // The legacy path rebuilds its pages from /state instead of clearing them.
2181
+ React.useEffect(() => {
2182
+ if (reload === 0) return
2183
+ if (transportRef.current === 'legacy') {
2184
+ setLegacyLimits({})
2185
+ return
2186
+ }
2187
+ resetPageCache(false)
2188
+ }, [reload])
2189
+
2190
+ // Old-Host fallback: pages are carved locally out of one /state payload.
2191
+ React.useEffect(() => {
2192
+ if (transport !== 'legacy' || legacyState === null) return
2193
+ const viewNow = computeView({
2194
+ state: legacyState,
2195
+ tab,
2196
+ workspaceFilter,
2197
+ query,
2198
+ sort,
2199
+ expanded: null,
2200
+ defaultExpanded: false,
2201
+ limits: {},
2202
+ showBlank,
2203
+ })
2204
+ const nextPages = new Map()
2205
+ for (const group of viewNow.groups) {
2206
+ nextPages.set(group.id, {
2207
+ rows: group.rows,
2208
+ total: group.rows.length,
2209
+ offset: group.rows.length,
2210
+ hasMore: false,
2211
+ phase: 'ready',
2212
+ error: null,
2213
+ })
2214
+ }
2215
+ pagesRef.current = nextPages
2216
+ setPages(nextPages)
2217
+ setRowCache((previous) => mergeRowCache(previous, viewNow.groups.flatMap((group) => group.rows)))
2218
+ setSizes({
2219
+ totalBytes: bytesOf(legacyState?.totals?.bytes),
2220
+ byWorkspace: new Map(viewNow.groups.map((group) => [group.id, group.bytes])),
2221
+ })
2222
+ setSizesPhase('ready')
2223
+ }, [transport, legacyState, scope, reload])
2224
+
2225
+ // Search is server-side and debounced through `query`; the applied query
2226
+ // is part of the scope key, so old results never survive a new search.
2227
+ React.useEffect(() => {
2228
+ if (transport !== 'paged' || query.trim() === '') return undefined
2229
+ void runSearch()
2230
+ return () => searchAbort.current?.abort()
2231
+ }, [transport, scope, reload])
2232
+
2233
+ React.useEffect(() => {
2234
+ if (transport !== 'paged') return
2235
+ void loadSizes()
2236
+ return () => sizesAbort.current?.abort()
2237
+ }, [transport, includeSubagents, reload])
2238
+
2239
+ // Typing is debounced; `query` only changes once the user pauses.
2240
+ React.useEffect(() => {
2241
+ const text = queryInput.trim()
2242
+ if (text === query) return undefined
2243
+ const timer = setTimeout(() => setQuery(text), SEARCH_DEBOUNCE_MS)
2244
+ return () => clearTimeout(timer)
2245
+ }, [queryInput, query])
1443
2246
 
1444
2247
  React.useEffect(() => {
1445
2248
  if (selectedId === null) return
1446
- const detail = document.querySelector('.dsm-detail')
1447
- const parent = scrollParentOf(detail)
2249
+ const detailElement = document.querySelector('.dsm-detail')
2250
+ const parent = scrollParentOf(detailElement)
1448
2251
  if (parent !== null && typeof parent.scrollTop === 'number') parent.scrollTop = 0
1449
2252
  }, [selectedId])
1450
2253
 
1451
- const openDetail = (id) => {
1452
- const page = document.querySelector('.dsm-page')
1453
- const parent = scrollParentOf(page)
1454
- listScroll.current = parent?.scrollTop ?? 0
1455
- setSelectedId(id)
1456
- }
1457
-
1458
- const backToList = () => {
1459
- setSelectedId(null)
1460
- requestAnimationFrame(() => {
1461
- const page = document.querySelector('.dsm-page')
1462
- const parent = scrollParentOf(page)
1463
- if (parent !== null && typeof parent.scrollTop === 'number') parent.scrollTop = listScroll.current
1464
- })
1465
- }
1466
-
1467
2254
  React.useEffect(() => {
1468
2255
  if (selectedId === null) return undefined
1469
2256
  const onKeyDown = (event) => {
@@ -1477,37 +2264,147 @@ window.__ModuleLoader__.load({
1477
2264
  return () => document.removeEventListener('keydown', onKeyDown, true)
1478
2265
  }, [selectedId])
1479
2266
 
2267
+ // Detail row: render the cached row immediately, then refresh it from the
2268
+ // dedicated route; only an explicit not-found clears the view.
1480
2269
  React.useEffect(() => {
1481
- setLimits({})
1482
- }, [tab, workspaceFilter, query, includeSubagents, showBlank])
2270
+ detailAbort.current?.abort()
2271
+ if (selectedId === null) {
2272
+ setDetail(null)
2273
+ setDetailPhase('idle')
2274
+ setDetailError(null)
2275
+ return undefined
2276
+ }
2277
+ const cached = rowCacheRef.current.get(selectedId)
2278
+ if (cached !== undefined) {
2279
+ setDetail(cached)
2280
+ setDetailPhase('ready')
2281
+ } else {
2282
+ setDetail(null)
2283
+ setDetailPhase('loading')
2284
+ }
2285
+ const controller = new AbortController()
2286
+ detailAbort.current = controller
2287
+ if (transportRef.current === 'legacy') {
2288
+ const stateNow = legacyStateRef.current
2289
+ const row = (stateNow?.sessions ?? []).find((item) => item.id === selectedId)
2290
+ if (row !== undefined) {
2291
+ setDetail(row)
2292
+ setDetailPhase('ready')
2293
+ } else if (cached === undefined) {
2294
+ setDetailPhase('missing')
2295
+ }
2296
+ return () => controller.abort()
2297
+ }
2298
+ getSession(selectedId, controller.signal)
2299
+ .then((value) => {
2300
+ if (controller.signal.aborted) return
2301
+ if (value?.row !== undefined && value?.row !== null) {
2302
+ setDetail(value.row)
2303
+ setDetailPhase('ready')
2304
+ setDetailError(null)
2305
+ setRowCache((previous) => mergeRowCache(previous, [value.row]))
2306
+ } else if (cached === undefined) {
2307
+ setDetailPhase('missing')
2308
+ }
2309
+ })
2310
+ .catch((failure) => {
2311
+ if (failure?.name === 'AbortError') return
2312
+ // The Host answers an unknown id with `not-found`; that is the only
2313
+ // case that may clear the detail view. A missing *route* (old Host)
2314
+ // keeps the cached row visible instead.
2315
+ if (failure?.code === 'not-found') {
2316
+ setDetail(null)
2317
+ setDetailPhase('missing')
2318
+ return
2319
+ }
2320
+ if (isMissingRoute(failure)) {
2321
+ if (cached === undefined) setDetailPhase('missing')
2322
+ return
2323
+ }
2324
+ if (cached === undefined) {
2325
+ setDetailError(failure)
2326
+ setDetailPhase('error')
2327
+ }
2328
+ })
2329
+ return () => controller.abort()
2330
+ }, [selectedId, detailNonce])
1483
2331
 
1484
- const rows = state?.sessions ?? []
1485
- const defaultExpanded = (state?.totals?.all ?? 0) <= DEFAULT_COLLAPSE_AFTER
2332
+ const totals = overview?.totals ?? {}
2333
+ const limits = overview?.limits ?? {}
2334
+ const defaultExpanded = (totals.all ?? 0) <= DEFAULT_COLLAPSE_AFTER
1486
2335
  const view = React.useMemo(
1487
- () => computeView({ state, tab, workspaceFilter, query, sort, expanded, defaultExpanded, limits, showBlank }),
1488
- [state, tab, workspaceFilter, query, sort, expanded, defaultExpanded, limits, showBlank],
1489
- )
1490
- const viewKey = React.useMemo(
1491
- () => [
1492
- state?.generatedAt ?? 0,
2336
+ () => composeGroups({
2337
+ overview,
2338
+ pages,
2339
+ sizes,
2340
+ expanded,
2341
+ defaultExpanded,
2342
+ query,
1493
2343
  tab,
1494
2344
  workspaceFilter,
1495
- query,
1496
- includeSubagents ? 1 : 0,
1497
- showBlank ? 1 : 0,
1498
- defaultExpanded ? 1 : 0,
1499
- expanded === null ? 'auto' : [...expanded].sort().join(','),
1500
- ].join('|'),
1501
- [state?.generatedAt, tab, workspaceFilter, query, includeSubagents, showBlank, defaultExpanded, expanded],
2345
+ showBlank,
2346
+ searchMode: transport === 'paged' && isSearch,
2347
+ requirePage: transport === 'legacy',
2348
+ legacy: transport === 'legacy',
2349
+ legacyLimits,
2350
+ }),
2351
+ [overview, pages, sizes, expanded, defaultExpanded, query, tab, workspaceFilter, showBlank, transport, isSearch, legacyLimits],
2352
+ )
2353
+ const expandedIdsKey = view.groups.filter((group) => group.expanded === true).map((group) => group.id).join('|')
2354
+ React.useEffect(() => {
2355
+ if (transport !== 'paged' || isSearch) return
2356
+ for (const group of view.groups) if (group.expanded === true) ensureGroupPage(group.id)
2357
+ }, [transport, scope, reload, expandedIdsKey])
2358
+
2359
+ const visibleKey = React.useMemo(
2360
+ () => view.groups.filter((group) => group.expanded === true).map((group) => group.rows.map((row) => row.id).join(',')).join('|'),
2361
+ [view],
1502
2362
  )
1503
2363
  React.useEffect(() => {
1504
2364
  setSelected((previous) => {
1505
2365
  if (previous.size === 0) return previous
2366
+ const visible = new Set()
2367
+ for (const group of view.groups) {
2368
+ if (group.expanded !== true) continue
2369
+ for (const row of group.rows) visible.add(row.id)
2370
+ }
1506
2371
  const next = new Set()
1507
- for (const id of previous) if (view.visibleIdSet.has(id)) next.add(id)
2372
+ for (const id of previous) if (visible.has(id)) next.add(id)
1508
2373
  return next.size === previous.size ? previous : next
1509
2374
  })
1510
- }, [viewKey])
2375
+ }, [visibleKey])
2376
+
2377
+ const rows = overview === null ? [] : view.groups
2378
+ const blankIds = blanks ?? { unarchivedIds: [], archivedIds: [], deletableIds: [] }
2379
+ const tabCounts = {
2380
+ all: Math.max(0, (totals.all ?? 0) - (showBlank ? 0 : (totals.blank ?? 0))),
2381
+ active: Math.max(0, (totals.active ?? 0) - (showBlank ? 0 : (blankIds.unarchivedIds.length ?? 0))),
2382
+ archived: Math.max(0, (totals.archived ?? 0) - (showBlank ? 0 : (blankIds.archivedIds.length ?? 0))),
2383
+ }
2384
+ const tabs = [
2385
+ { key: 'all', label: t('tabAll'), count: tabCounts.all },
2386
+ { key: 'active', label: t('tabActive'), count: tabCounts.active },
2387
+ { key: 'archived', label: t('tabArchived'), count: tabCounts.archived },
2388
+ ]
2389
+ const maxBatch = limits.maxBatch ?? 50
2390
+ const maxMoveBatch = limits.maxMoveBatch ?? 20
2391
+ const selectedRows = [...selected].map((id) => rowCache.get(id)).filter((row) => row !== undefined)
2392
+ const visibleAllSelected = view.visibleRows.length > 0 && view.visibleRows.every((row) => selected.has(row.id))
2393
+ const overBatch = selectedRows.length > maxBatch
2394
+ const overMove = selectedRows.length > maxMoveBatch
2395
+ const selectedArchived = selectedRows.length > 0 && !overBatch && selectedRows.every((row) => row.archived)
2396
+ const selectedActive = selectedRows.length > 0 && !overBatch && selectedRows.every((row) => !row.archived)
2397
+ const selectedMovable =
2398
+ selectedRows.length > 0 && !overMove &&
2399
+ selectedRows.every((row) => row.can?.move === true) &&
2400
+ new Set(selectedRows.map((row) => row.workspaceId)).size === 1
2401
+ const selectedDeletable = selectedRows.length > 0 && !overBatch && selectedRows.every((row) => row.can?.delete === true)
2402
+ const targetWorkspaces = (overview?.workspaces ?? []).filter((workspace) => {
2403
+ if (workspace.status !== 'ok') return false
2404
+ return !selectedRows.some((row) => row.workspaceId === workspace.id)
2405
+ })
2406
+ const totalBytes = sizes?.totalBytes
2407
+ const sizeText = Number.isFinite(totalBytes) ? formatBytes(totalBytes) : '—'
1511
2408
 
1512
2409
  const run = async (operation, successText) => {
1513
2410
  setBusy(true)
@@ -1515,7 +2412,7 @@ window.__ModuleLoader__.load({
1515
2412
  try {
1516
2413
  const value = await operation()
1517
2414
  setNotice({ tone: 'ok', text: successText(value) })
1518
- await load({ silent: true })
2415
+ await refresh()
1519
2416
  return value
1520
2417
  } catch (failure) {
1521
2418
  setNotice({ tone: 'error', text: errorText(t, failure), detail: failure?.message })
@@ -1526,9 +2423,9 @@ window.__ModuleLoader__.load({
1526
2423
  }
1527
2424
 
1528
2425
  const resultText = (value) => {
1529
- const totals = value?.totals
1530
- if (totals === undefined) return t('working')
1531
- return t('batchDone', { ok: totals.succeeded, failed: totals.failed })
2426
+ const valueTotals = value?.totals
2427
+ if (valueTotals === undefined) return t('working')
2428
+ return t('batchDone', { ok: valueTotals.succeeded, failed: valueTotals.failed })
1532
2429
  }
1533
2430
 
1534
2431
  const archiveRows = async (ids, stopActivity = false) => {
@@ -1552,40 +2449,96 @@ window.__ModuleLoader__.load({
1552
2449
  }
1553
2450
 
1554
2451
  const moveRows = async (ids, workspaceId) => {
1555
- const value = await run(
2452
+ return await run(
1556
2453
  () => post('move', { sessionIds: ids, workspaceId }),
1557
2454
  (result) => resultText(result),
1558
2455
  )
1559
- return value
1560
2456
  }
1561
2457
 
2458
+ /**
2459
+ * Permanently delete sessions in backend-sized batches. One confirmation
2460
+ * may cover more ids than a single request admits (the empty-session
2461
+ * shortcut can), so the request is split and the totals are summed into
2462
+ * one notice.
2463
+ */
1562
2464
  const deleteRows = async (ids) => {
1563
- const value = await run(
1564
- () => post('delete', { sessionIds: ids, confirm: true }),
1565
- (result) => resultText(result),
1566
- )
1567
- if (value !== undefined) {
2465
+ if (ids.length === 0) return undefined
2466
+ setBusy(true)
2467
+ setNotice(null)
2468
+ try {
2469
+ let succeeded = 0
2470
+ let failed = 0
2471
+ const failures = []
2472
+ for (let index = 0; index < ids.length; index += maxBatch) {
2473
+ const value = await post('delete', { sessionIds: ids.slice(index, index + maxBatch), confirm: true })
2474
+ succeeded += value?.totals?.succeeded ?? 0
2475
+ failed += value?.totals?.failed ?? 0
2476
+ for (const item of value?.results ?? []) if (item.ok === false) failures.push(item)
2477
+ }
2478
+ setNotice(
2479
+ failed === 0
2480
+ ? { tone: 'ok', text: t('batchDone', { ok: succeeded, failed: 0 }) }
2481
+ : { tone: 'error', text: t('batchDone', { ok: succeeded, failed }), details: failures.slice(0, 20) },
2482
+ )
1568
2483
  setBulkDeleteIds(null)
1569
2484
  setSelected(new Set())
1570
2485
  if (selectedId !== null && ids.includes(selectedId)) setSelectedId(null)
2486
+ await refresh()
2487
+ return { totals: { succeeded, failed } }
2488
+ } catch (failure) {
2489
+ setNotice({ tone: 'error', text: errorText(t, failure), detail: failure?.message })
2490
+ return undefined
2491
+ } finally {
2492
+ setBusy(false)
1571
2493
  }
1572
- return value
1573
2494
  }
1574
2495
 
1575
2496
  const cleanupOrphans = async () => {
1576
- const ids = state?.orphans ?? []
2497
+ const ids = overview?.orphans ?? []
1577
2498
  if (ids.length === 0) return
1578
- const max = state?.limits?.maxBatch ?? 50
1579
2499
  setBusy(true)
1580
2500
  setNotice(null)
1581
2501
  try {
1582
2502
  let cleaned = 0
1583
- for (let index = 0; index < ids.length; index += max) {
1584
- await post('unarchive', { sessionIds: ids.slice(index, index + max) })
1585
- cleaned += Math.min(max, ids.length - index)
2503
+ for (let index = 0; index < ids.length; index += maxBatch) {
2504
+ await post('unarchive', { sessionIds: ids.slice(index, index + maxBatch) })
2505
+ cleaned += Math.min(maxBatch, ids.length - index)
1586
2506
  }
1587
2507
  setNotice({ tone: 'ok', text: t('cleanedOrphans', { n: cleaned }) })
1588
- await load({ silent: true })
2508
+ await refresh()
2509
+ } catch (failure) {
2510
+ setNotice({ tone: 'error', text: errorText(t, failure) })
2511
+ } finally {
2512
+ setBusy(false)
2513
+ }
2514
+ }
2515
+
2516
+ /**
2517
+ * Archive every unarchived empty session, in backend-sized batches. The
2518
+ * user can then clean them up with one confirm dialog.
2519
+ */
2520
+ const archiveBlankSessions = async (ids) => {
2521
+ if (ids.length === 0) return
2522
+ setBusy(true)
2523
+ setNotice(null)
2524
+ try {
2525
+ let archived = 0
2526
+ const failures = []
2527
+ for (let index = 0; index < ids.length; index += maxBatch) {
2528
+ const value = await post('archive', { sessionIds: ids.slice(index, index + maxBatch) })
2529
+ archived += value?.totals?.succeeded ?? 0
2530
+ for (const item of value?.results ?? []) if (item.ok === false) failures.push(item)
2531
+ }
2532
+ if (failures.length === 0) {
2533
+ setNotice({ tone: 'ok', text: t('archiveDone', { n: archived }) })
2534
+ } else {
2535
+ setNotice({
2536
+ tone: 'error',
2537
+ text: t('batchDone', { ok: archived, failed: failures.length }),
2538
+ details: failures.slice(0, 20),
2539
+ })
2540
+ }
2541
+ await refresh()
1589
2542
  } catch (failure) {
1590
2543
  setNotice({ tone: 'error', text: errorText(t, failure) })
1591
2544
  } finally {
@@ -1614,43 +2567,21 @@ window.__ModuleLoader__.load({
1614
2567
  if (value !== undefined) openSession(id)
1615
2568
  }
1616
2569
 
1617
- if (state === null) {
1618
- if (phase === 'error') {
1619
- return h(
1620
- 'div',
1621
- { className: 'dsm-page' },
1622
- h('h2', { className: 'dsm-title' }, t('pageTitle')),
1623
- h('p', { className: 'dsm-error' }, t('loadFailed', { message: errorText(t, error) })),
1624
- h('button', { type: 'button', className: 'dsm-btn', onClick: () => void load() }, t('retry')),
1625
- )
1626
- }
1627
- return h('div', { className: 'dsm-page' }, h('p', { className: 'dsm-hint' }, h(Spinner, { t, label: t('loading') })))
2570
+ const openDetail = (id) => {
2571
+ const page = document.querySelector('.dsm-page')
2572
+ const parent = scrollParentOf(page)
2573
+ listScroll.current = parent?.scrollTop ?? 0
2574
+ setSelectedId(id)
1628
2575
  }
1629
2576
 
1630
- const rowsForTabs = rows.filter((row) => showBlank || row.blank !== true)
1631
- const blankRows = rows.filter((row) => row.blank === true)
1632
- const blankArchivedRows = blankRows.filter((row) => row.archived === true && row.can?.delete === true)
1633
- const blankActiveCount = blankRows.filter((row) => row.archived !== true || row.can?.delete !== true).length
1634
- const tabs = [
1635
- { key: 'all', label: t('tabAll'), count: rowsForTabs.length },
1636
- { key: 'active', label: t('tabActive'), count: rowsForTabs.filter((row) => !row.archived).length },
1637
- { key: 'archived', label: t('tabArchived'), count: rowsForTabs.filter((row) => row.archived).length },
1638
- ]
1639
- const visibleAllSelected = view.visibleRows.length > 0 && view.visibleRows.every((row) => selected.has(row.id))
1640
- const selectedRows = view.visibleRows.filter((row) => selected.has(row.id))
1641
- const overBatch = selectedRows.length > (state.limits?.maxBatch ?? 50)
1642
- const overMove = selectedRows.length > (state.limits?.maxMoveBatch ?? 20)
1643
- const selectedArchived = selectedRows.length > 0 && !overBatch && selectedRows.every((row) => row.archived)
1644
- const selectedActive = selectedRows.length > 0 && !overBatch && selectedRows.every((row) => !row.archived)
1645
- const selectedMovable =
1646
- selectedRows.length > 0 && !overMove &&
1647
- selectedRows.every((row) => row.can?.move === true) &&
1648
- new Set(selectedRows.map((row) => row.workspaceId)).size === 1
1649
- const selectedDeletable = selectedRows.length > 0 && !overBatch && selectedRows.every((row) => row.can?.delete === true)
1650
- const targetWorkspaces = (state?.workspaces ?? []).filter((workspace) => {
1651
- if (workspace.status !== 'ok') return false
1652
- return !selectedRows.some((row) => row.workspaceId === workspace.id)
1653
- })
2577
+ const backToList = () => {
2578
+ setSelectedId(null)
2579
+ requestAnimationFrame(() => {
2580
+ const page = document.querySelector('.dsm-page')
2581
+ const parent = scrollParentOf(page)
2582
+ if (parent !== null && typeof parent.scrollTop === 'number') parent.scrollTop = listScroll.current
2583
+ })
2584
+ }
1654
2585
 
1655
2586
  const toggle = (id) => {
1656
2587
  setSelected((previous) => {
@@ -1689,16 +2620,44 @@ window.__ModuleLoader__.load({
1689
2620
  const expandAll = () => setExpanded(new Set(view.groups.map((group) => group.id)))
1690
2621
  const collapseAll = () => setExpanded(new Set())
1691
2622
  const loadMore = (id) => {
1692
- setLimits((previous) => ({ ...previous, [id]: (previous[id] ?? GROUP_PAGE_SIZE) + GROUP_PAGE_SIZE }))
2623
+ if (transportRef.current === 'legacy') {
2624
+ setLegacyLimits((previous) => ({ ...previous, [id]: (previous[id] ?? GROUP_PAGE_SIZE) + GROUP_PAGE_SIZE }))
2625
+ return
2626
+ }
2627
+ loadMoreGroup(id)
2628
+ }
2629
+
2630
+ if (overview === null) {
2631
+ if (overviewPhase === 'error') {
2632
+ return h(
2633
+ 'div',
2634
+ { className: 'dsm-page' },
2635
+ h('h2', { className: 'dsm-title' }, t('pageTitle')),
2636
+ h('p', { className: 'dsm-error' }, t('overviewLoadFailed', { message: errorText(t, overviewError) })),
2637
+ h('button', { type: 'button', className: 'dsm-btn', onClick: () => void loadOverview() }, t('retry')),
2638
+ )
2639
+ }
2640
+ return h('div', { className: 'dsm-page' }, h('p', { className: 'dsm-hint' }, h(Spinner, { t, label: t('loading') })))
1693
2641
  }
1694
- const isSearch = query.trim() !== ''
1695
2642
 
1696
2643
  if (selectedId !== null) {
1697
- const row = state.sessions.find((item) => item.id === selectedId)
2644
+ const row = detail ?? rowCache.get(selectedId)
1698
2645
  if (row === undefined) {
1699
- return h('div', { className: 'dsm-page' },
1700
- h('button', { type: 'button', className: 'dsm-btn ghost', onClick: backToList }, `← ${t('back')}`),
1701
- h('div', { className: 'dsm-empty' }, t('notFound')),
2646
+ const failed = detailPhase === 'error'
2647
+ const missing = detailPhase === 'missing'
2648
+ return h(
2649
+ 'div',
2650
+ { className: 'dsm-page' },
2651
+ h('div', { className: 'dsm-actions', style: { marginBottom: 4 } },
2652
+ h('button', { type: 'button', className: 'dsm-btn ghost', onClick: backToList }, `← ${t('back')}`),
2653
+ ),
2654
+ failed || missing
2655
+ ? h('p', { className: failed ? 'dsm-error' : 'dsm-empty' },
2656
+ failed ? t('loadFailed', { message: errorText(t, detailError) }) : t('notFound'))
2657
+ : h('p', { className: 'dsm-hint' }, h(Spinner, { t, label: t('loading') })),
2658
+ failed
2659
+ ? h('button', { type: 'button', className: 'dsm-btn', onClick: () => setDetailNonce((value) => value + 1) }, t('retry'))
2660
+ : null,
1702
2661
  )
1703
2662
  }
1704
2663
  return h(
@@ -1708,13 +2667,14 @@ window.__ModuleLoader__.load({
1708
2667
  h(DetailView, {
1709
2668
  ctx,
1710
2669
  t,
1711
- state,
2670
+ overview,
2671
+ limits,
1712
2672
  row,
1713
2673
  busy,
1714
2674
  onBack: backToList,
1715
2675
  onChanged: (text) => {
1716
2676
  setNotice({ tone: 'ok', text: text ?? t('copied') })
1717
- void load({ silent: true })
2677
+ void refresh()
1718
2678
  },
1719
2679
  onOpen: openSession,
1720
2680
  onArchive: (id) => void archiveRows([id]),
@@ -1731,46 +2691,67 @@ window.__ModuleLoader__.load({
1731
2691
  h(
1732
2692
  'div',
1733
2693
  { className: 'dsm-head' },
1734
- h('div', null,
2694
+ h(
2695
+ 'div',
2696
+ { className: 'dsm-head-row' },
1735
2697
  h('h2', { className: 'dsm-title' }, t('pageTitle')),
1736
- h('p', { className: 'dsm-sub' }, t('pageSubtitle')),
1737
- h('p', { className: 'dsm-sub' },
1738
- t('totalLine', {
1739
- all: state.totals.all,
1740
- archived: state.totals.archived,
1741
- size: formatBytes(state.totals.bytes),
1742
- }),
1743
- blankRows.length > 0 && !showBlank
1744
- ? ` · ${t('hiddenBlankCount', { n: blankRows.length })}`
1745
- : '',
1746
- (state.totals?.hiddenSubagents ?? 0) > 0 && !includeSubagents
1747
- ? ` · ${t('hiddenSubagentCount', { n: state.totals.hiddenSubagents })}`
1748
- : '',
2698
+ h('div', { className: 'dsm-head-actions' },
2699
+ blankIds.unarchivedIds.length > 0
2700
+ ? h('button', {
2701
+ type: 'button',
2702
+ className: 'dsm-btn',
2703
+ disabled: busy,
2704
+ title: t('archiveBlankHint'),
2705
+ onClick: () => void archiveBlankSessions(blankIds.unarchivedIds),
2706
+ }, t('archiveBlank', { n: blankIds.unarchivedIds.length }))
2707
+ : null,
2708
+ blankIds.archivedIds.length > 0
2709
+ ? h('button', {
2710
+ type: 'button',
2711
+ className: 'dsm-btn',
2712
+ disabled: busy || blankIds.deletableIds.length === 0,
2713
+ title: blankIds.deletableIds.length === blankIds.archivedIds.length
2714
+ ? t('cleanupBlankHint')
2715
+ : t('cleanupBlankLockedHint', { n: blankIds.archivedIds.length - blankIds.deletableIds.length }),
2716
+ onClick: () => setBulkDeleteIds(blankIds.deletableIds),
2717
+ }, t('cleanupBlank', { n: blankIds.archivedIds.length }))
2718
+ : null,
2719
+ h('button', { type: 'button', className: 'dsm-btn', disabled: busy, onClick: () => void refresh() },
2720
+ busy ? h(Spinner, { t, label: t('working') }) : t('refresh')),
1749
2721
  ),
1750
- blankActiveCount > 0 && showBlank
1751
- ? h('p', { className: 'dsm-hint' }, t('blankActiveHint', { n: blankActiveCount }))
1752
- : null,
1753
2722
  ),
1754
- h('div', { className: 'dsm-head-actions' },
1755
- blankArchivedRows.length > 0
1756
- ? h('button', {
1757
- type: 'button',
1758
- className: 'dsm-btn',
1759
- disabled: busy,
1760
- title: `${t('cleanupBlankHint')}${blankActiveCount > 0 ? ` ${t('blankActiveHint', { n: blankActiveCount })}` : ''}`,
1761
- onClick: () => setBulkDeleteIds(blankArchivedRows.map((row) => row.id)),
1762
- }, t('cleanupBlank', { n: blankArchivedRows.length }))
1763
- : null,
1764
- h('button', { type: 'button', className: 'dsm-btn', disabled: busy, onClick: () => void load({ silent: true }) },
1765
- busy ? h(Spinner, { t, label: t('working') }) : t('refresh')),
2723
+ h('p', { className: 'dsm-sub' }, t('pageSubtitle')),
2724
+ h('p', { className: 'dsm-sub' },
2725
+ t('totalLine', {
2726
+ all: totals.all ?? 0,
2727
+ archived: totals.archived ?? 0,
2728
+ size: sizeText,
2729
+ }),
2730
+ (totals.blank ?? 0) > 0 && !showBlank
2731
+ ? ` · ${t('hiddenBlankCount', { n: totals.blank })}`
2732
+ : '',
2733
+ (totals.hiddenSubagents ?? 0) > 0 && !includeSubagents
2734
+ ? ` · ${t('hiddenSubagentCount', { n: totals.hiddenSubagents })}`
2735
+ : '',
2736
+ sizesPhase === 'loading' ? ` · ${t('sizesPending')}` : '',
1766
2737
  ),
1767
2738
  ),
1768
2739
  h(Notice, { notice, t, onDismiss: () => setNotice(null) }),
1769
- state.orphans.length > 0
2740
+ overviewPhase === 'error'
2741
+ ? h(
2742
+ 'div',
2743
+ { className: 'dsm-note warn' },
2744
+ h('div', { style: { flex: '1 1 auto' } }, t('overviewLoadFailed', { message: errorText(t, overviewError) })),
2745
+ h('div', { className: 'dsm-note-actions' },
2746
+ h('button', { type: 'button', className: 'dsm-btn', disabled: busy, onClick: () => void refresh() }, t('retry')),
2747
+ ),
2748
+ )
2749
+ : null,
2750
+ (overview?.orphans?.length ?? 0) > 0
1770
2751
  ? h(
1771
2752
  'div',
1772
2753
  { className: 'dsm-note warn' },
1773
- h('div', { style: { flex: '1 1 auto' } }, t('orphanBanner', { n: state.orphans.length })),
2754
+ h('div', { style: { flex: '1 1 auto' } }, t('orphanBanner', { n: overview.orphans.length })),
1774
2755
  h('div', { className: 'dsm-note-actions' },
1775
2756
  h('button', { type: 'button', className: 'dsm-btn', disabled: busy, onClick: () => void cleanupOrphans() },
1776
2757
  busy ? t('cleaningOrphans') : t('cleanupOrphans')),
@@ -1778,139 +2759,142 @@ window.__ModuleLoader__.load({
1778
2759
  )
1779
2760
  : null,
1780
2761
  h('div', { className: 'dsm-sticky' },
1781
- h(
1782
- 'div',
1783
- { className: 'dsm-toolbar' },
1784
- h('div', { className: 'dsm-tabs', role: 'group' },
1785
- tabs.map((item) =>
1786
- h('button', {
1787
- key: item.key,
1788
- type: 'button',
1789
- className: 'dsm-tab',
1790
- 'aria-pressed': tab === item.key ? 'true' : 'false',
1791
- onClick: () => setTab(item.key),
1792
- }, `${item.label} ${item.count}`),
2762
+ h(
2763
+ 'div',
2764
+ { className: 'dsm-toolbar' },
2765
+ h('div', { className: 'dsm-tabs', role: 'group' },
2766
+ tabs.map((item) =>
2767
+ h('button', {
2768
+ key: item.key,
2769
+ type: 'button',
2770
+ className: 'dsm-tab',
2771
+ 'aria-pressed': tab === item.key ? 'true' : 'false',
2772
+ onClick: () => setTab(item.key),
2773
+ }, `${item.label} ${item.count}`),
2774
+ ),
1793
2775
  ),
1794
- ),
1795
- h('select', {
1796
- className: 'dsm-select',
1797
- value: workspaceFilter,
1798
- onChange: (event) => setWorkspaceFilter(event.target.value),
1799
- 'aria-label': t('workspaceAll'),
1800
- },
1801
- h('option', { value: 'all' }, t('workspaceAll')),
1802
- ...(state.workspaces ?? []).map((workspace) =>
1803
- h('option', { key: workspace.id, value: workspace.id },
1804
- `${workspace.title || workspace.path}${workspace.status === 'ok' ? '' : ` · ${t('workspaceMissing')}`}`),
2776
+ h('select', {
2777
+ className: 'dsm-select',
2778
+ value: workspaceFilter,
2779
+ onChange: (event) => setWorkspaceFilter(event.target.value),
2780
+ 'aria-label': t('workspaceAll'),
2781
+ },
2782
+ h('option', { value: 'all' }, t('workspaceAll')),
2783
+ ...(overview?.workspaces ?? []).map((workspace) =>
2784
+ h('option', { key: workspace.id, value: workspace.id },
2785
+ `${workspace.title || workspace.path}${workspace.status === 'ok' ? '' : ` · ${t('workspaceMissing')}`}`),
2786
+ ),
2787
+ h('option', { value: 'ungrouped' }, t('workspaceUngrouped')),
1805
2788
  ),
1806
- h('option', { value: 'ungrouped' }, t('workspaceUngrouped')),
1807
- ),
1808
- h('input', {
1809
- ref: searchRef,
1810
- className: 'dsm-input',
1811
- type: 'search',
1812
- value: query,
1813
- placeholder: t('searchPlaceholder'),
1814
- 'aria-label': t('searchPlaceholder'),
1815
- onChange: (event) => setQuery(event.target.value),
1816
- }),
1817
- h('select', {
1818
- className: 'dsm-select',
1819
- value: sort,
1820
- onChange: (event) => setSort(event.target.value),
1821
- 'aria-label': t('sortUpdated'),
1822
- },
1823
- h('option', { value: 'updated' }, t('sortUpdated')),
1824
- h('option', { value: 'created' }, t('sortCreated')),
1825
- h('option', { value: 'size' }, t('sortSize')),
1826
- h('option', { value: 'title' }, t('sortTitle')),
1827
- ),
1828
- h('label', { className: 'dsm-check', title: t('showSubagentsHint') },
1829
- h('input', {
1830
- type: 'checkbox',
1831
- checked: includeSubagents,
1832
- onChange: (event) => setIncludeSubagents(event.target.checked),
1833
- }),
1834
- t('showSubagents'),
1835
- ),
1836
- h('label', { className: 'dsm-check' },
1837
2789
  h('input', {
1838
- type: 'checkbox',
1839
- checked: showBlank,
1840
- onChange: (event) => setShowBlank(event.target.checked),
2790
+ ref: searchRef,
2791
+ className: 'dsm-input',
2792
+ type: 'search',
2793
+ value: queryInput,
2794
+ placeholder: t('searchPlaceholder'),
2795
+ 'aria-label': t('searchPlaceholder'),
2796
+ onChange: (event) => setQueryInput(event.target.value),
1841
2797
  }),
1842
- t('showBlank'),
2798
+ h('select', {
2799
+ className: 'dsm-select',
2800
+ value: sort,
2801
+ onChange: (event) => setSort(event.target.value),
2802
+ 'aria-label': t('sortUpdated'),
2803
+ },
2804
+ h('option', { value: 'updated' }, t('sortUpdated')),
2805
+ h('option', { value: 'created' }, t('sortCreated')),
2806
+ h('option', { value: 'size' }, t('sortSize')),
2807
+ h('option', { value: 'title' }, t('sortTitle')),
2808
+ ),
2809
+ h('label', { className: 'dsm-check', title: t('showSubagentsHint') },
2810
+ h('input', {
2811
+ type: 'checkbox',
2812
+ checked: includeSubagents,
2813
+ onChange: (event) => setIncludeSubagents(event.target.checked),
2814
+ }),
2815
+ t('showSubagents'),
2816
+ ),
2817
+ h('label', { className: 'dsm-check' },
2818
+ h('input', {
2819
+ type: 'checkbox',
2820
+ checked: showBlank,
2821
+ onChange: (event) => setShowBlank(event.target.checked),
2822
+ }),
2823
+ t('showBlank'),
2824
+ ),
1843
2825
  ),
1844
- ),
1845
- view.visibleRows.length > 0
1846
- ? h(
1847
- 'div',
1848
- { className: 'dsm-batch' },
1849
- h('label', { className: 'dsm-check' },
1850
- h('input', { type: 'checkbox', checked: visibleAllSelected, onChange: toggleAll }),
1851
- t('selectAll'),
1852
- ),
1853
- selectedRows.length > 0
1854
- ? h(React.Fragment, null,
1855
- h('span', { className: 'dsm-hint' }, t('selectedCount', { n: selectedRows.length })),
1856
- h('span', { className: 'dsm-hint' }, t('selectionScopeHint')),
1857
- overBatch ? h('span', { className: 'dsm-warn' }, t('tooMany', { max: state.limits?.maxBatch ?? 50 })) : null,
1858
- overMove ? h('span', { className: 'dsm-warn' }, t('tooMany', { max: state.limits?.maxMoveBatch ?? 20 })) : null,
1859
- h('button', {
1860
- type: 'button',
1861
- className: 'dsm-btn',
1862
- disabled: busy || !selectedActive,
1863
- title: selectedActive ? t('archiveHint') : t('noAction'),
1864
- onClick: () => void archiveRows(selectedRows.map((row) => row.id)),
1865
- }, t('archiveSelected')),
1866
- h('button', {
1867
- type: 'button',
1868
- className: 'dsm-btn',
1869
- disabled: busy || !selectedArchived,
1870
- onClick: () => void restoreRows(selectedRows.map((row) => row.id)),
1871
- }, t('restoreSelected')),
1872
- h('select', {
1873
- className: 'dsm-select',
1874
- value: bulkTarget,
1875
- disabled: busy || !selectedMovable,
1876
- onChange: (event) => setBulkTarget(event.target.value),
1877
- 'aria-label': t('moveTo'),
1878
- },
1879
- h('option', { value: '' }, t('moveTo')),
1880
- ...targetWorkspaces.map((workspace) =>
1881
- h('option', { key: workspace.id, value: workspace.id }, workspace.title || workspace.path),
2826
+ view.visibleRows.length > 0
2827
+ ? h(
2828
+ 'div',
2829
+ { className: 'dsm-batch' },
2830
+ h('label', { className: 'dsm-check' },
2831
+ h('input', { type: 'checkbox', checked: visibleAllSelected, onChange: toggleAll }),
2832
+ t('selectAll'),
2833
+ ),
2834
+ selectedRows.length > 0
2835
+ ? h(React.Fragment, null,
2836
+ h('span', { className: 'dsm-hint' }, t('selectedCount', { n: selectedRows.length })),
2837
+ h('span', { className: 'dsm-hint' }, t('selectionScopeHint')),
2838
+ view.partialCount > 0
2839
+ ? h('span', { className: 'dsm-hint' }, t('partialSelection', { n: view.partialCount }))
2840
+ : null,
2841
+ overBatch ? h('span', { className: 'dsm-warn' }, t('tooMany', { max: maxBatch })) : null,
2842
+ overMove ? h('span', { className: 'dsm-warn' }, t('tooMany', { max: maxMoveBatch })) : null,
2843
+ h('button', {
2844
+ type: 'button',
2845
+ className: 'dsm-btn',
2846
+ disabled: busy || !selectedActive,
2847
+ title: selectedActive ? t('archiveHint') : t('noAction'),
2848
+ onClick: () => void archiveRows(selectedRows.map((row) => row.id)),
2849
+ }, t('archiveSelected')),
2850
+ h('button', {
2851
+ type: 'button',
2852
+ className: 'dsm-btn',
2853
+ disabled: busy || !selectedArchived,
2854
+ onClick: () => void restoreRows(selectedRows.map((row) => row.id)),
2855
+ }, t('restoreSelected')),
2856
+ h('select', {
2857
+ className: 'dsm-select',
2858
+ value: bulkTarget,
2859
+ disabled: busy || !selectedMovable,
2860
+ onChange: (event) => setBulkTarget(event.target.value),
2861
+ 'aria-label': t('moveTo'),
2862
+ },
2863
+ h('option', { value: '' }, t('moveTo')),
2864
+ ...targetWorkspaces.map((workspace) =>
2865
+ h('option', { key: workspace.id, value: workspace.id }, workspace.title || workspace.path),
2866
+ ),
1882
2867
  ),
1883
- ),
1884
- h('button', {
1885
- type: 'button',
1886
- className: 'dsm-btn',
1887
- disabled: busy || !selectedMovable || bulkTarget === '',
1888
- onClick: () => void moveRows(selectedRows.map((row) => row.id), bulkTarget),
1889
- }, t('moveSelected')),
1890
- h('button', {
1891
- type: 'button',
1892
- className: 'dsm-btn danger',
1893
- disabled: busy || !selectedDeletable,
1894
- onClick: () => setBulkDeleteIds(selectedRows.map((row) => row.id)),
1895
- }, t('deleteSelected')),
1896
- h('button', {
1897
- type: 'button',
1898
- className: 'dsm-btn ghost',
1899
- disabled: busy,
1900
- onClick: () => setSelected(new Set()),
1901
- }, t('clearSelection')),
1902
- !selectedMovable && selectedRows.length > 0
1903
- ? h('span', { className: 'dsm-hint' },
1904
- selectedRows.some((row) => row.live || row.running)
1905
- ? t('liveHint')
1906
- : selectedRows.some((row) => row.kind === 'fork')
1907
- ? t('moveForkHint')
1908
- : t('moveUnavailableHint'))
1909
- : null,
1910
- )
1911
- : null,
1912
- )
1913
- : null,
2868
+ h('button', {
2869
+ type: 'button',
2870
+ className: 'dsm-btn',
2871
+ disabled: busy || !selectedMovable || bulkTarget === '',
2872
+ onClick: () => void moveRows(selectedRows.map((row) => row.id), bulkTarget),
2873
+ }, t('moveSelected')),
2874
+ h('button', {
2875
+ type: 'button',
2876
+ className: 'dsm-btn danger',
2877
+ disabled: busy || !selectedDeletable,
2878
+ onClick: () => setBulkDeleteIds(selectedRows.map((row) => row.id)),
2879
+ }, t('deleteSelected')),
2880
+ h('button', {
2881
+ type: 'button',
2882
+ className: 'dsm-btn ghost',
2883
+ disabled: busy,
2884
+ onClick: () => setSelected(new Set()),
2885
+ }, t('clearSelection')),
2886
+ !selectedMovable && selectedRows.length > 0
2887
+ ? h('span', { className: 'dsm-hint' },
2888
+ selectedRows.some((row) => row.live || row.running)
2889
+ ? t('liveHint')
2890
+ : selectedRows.some((row) => row.kind === 'fork')
2891
+ ? t('moveForkHint')
2892
+ : t('moveUnavailableHint'))
2893
+ : null,
2894
+ )
2895
+ : null,
2896
+ )
2897
+ : null,
1914
2898
  ),
1915
2899
  h('div', { className: 'dsm-list-actions' },
1916
2900
  h('button', {
@@ -1927,18 +2911,29 @@ window.__ModuleLoader__.load({
1927
2911
  }, t('collapseAll')),
1928
2912
  isSearch ? h('span', { className: 'dsm-hint' }, t('searchAutoExpand')) : null,
1929
2913
  ),
2914
+ isSearch && searchPhase === 'error'
2915
+ ? h(
2916
+ 'div',
2917
+ { className: 'dsm-note warn' },
2918
+ h('div', { style: { flex: '1 1 auto' } }, t('groupLoadFailed', { message: errorText(t, searchError) })),
2919
+ h('div', { className: 'dsm-note-actions' },
2920
+ h('button', { type: 'button', className: 'dsm-btn', disabled: busy, onClick: () => setReload((value) => value + 1) }, t('groupRetry')),
2921
+ ),
2922
+ )
2923
+ : null,
1930
2924
  bulkDeleteIds !== null
1931
2925
  ? h(DeleteScope, {
1932
2926
  ctx,
1933
2927
  t,
1934
2928
  ids: bulkDeleteIds,
2929
+ maxBatch,
1935
2930
  busy,
1936
2931
  onCancel: () => setBulkDeleteIds(null),
1937
2932
  onConfirmed: (ids) => void deleteRows(ids),
1938
2933
  })
1939
2934
  : null,
1940
- view.groups.length > 0
1941
- ? view.groups.map((group) =>
2935
+ rows.length > 0
2936
+ ? rows.map((group) =>
1942
2937
  h(Group, {
1943
2938
  key: group.id,
1944
2939
  group,
@@ -1947,11 +2942,14 @@ window.__ModuleLoader__.load({
1947
2942
  onSelect: toggle,
1948
2943
  onOpen: openDetail,
1949
2944
  onMore: () => loadMore(group.id),
2945
+ onRetry: () => retryGroup(group.id),
1950
2946
  locked: isSearch,
1951
2947
  t,
1952
2948
  }),
1953
2949
  )
1954
- : h('div', { className: 'dsm-empty' }, rows.length === 0 ? t('emptyNoSessions') : t('emptyFiltered')),
2950
+ : isSearch && searchPhase === 'loading'
2951
+ ? h('div', { className: 'dsm-empty' }, h(Spinner, { t, label: t('groupLoading') }))
2952
+ : h('div', { className: 'dsm-empty' }, (totals.all ?? 0) === 0 ? t('emptyNoSessions') : t('emptyFiltered')),
1955
2953
  h('p', { className: 'dsm-footer' }, t('archiveHint')),
1956
2954
  )
1957
2955
  }
@@ -1959,6 +2957,24 @@ window.__ModuleLoader__.load({
1959
2957
 
1960
2958
  return {
1961
2959
  inject: ['slots', 'locale'],
2960
+ /** Pure helpers for `node --test`; not part of the Host/client contract. */
2961
+ __test: {
2962
+ computeView,
2963
+ orderBranchRows,
2964
+ pruneSelection,
2965
+ scopeKey,
2966
+ dataScopeKey,
2967
+ queryOf,
2968
+ mergePageRows,
2969
+ mergeRowCache,
2970
+ annotateBranchRows,
2971
+ overviewFromState,
2972
+ blanksFromState,
2973
+ normalizeSizes,
2974
+ composeGroups,
2975
+ /** Components are exposed only so the vm test can smoke their render bodies. */
2976
+ components: { SessionManagerPage, Row, Group, DetailView },
2977
+ },
1962
2978
  apply(ctx) {
1963
2979
  const t = ctx.locale.bind(NS)
1964
2980
  ctx.effect(() => ctx.locale.register(NS, DICT), 'session-mng: dictionaries')