dsh-session-mng 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -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: {
@@ -29,6 +33,8 @@ window.__ModuleLoader__.load({
29
33
  pageTitle: '会话管理',
30
34
  pageSubtitle: '管理当前 profile 的全部会话:标题、归档、移动与彻底删除。',
31
35
  refresh: '刷新',
36
+ reloadPage: '刷新页面',
37
+ reloadPageHint: '重新加载整个 DSH 页面:侧栏会话列表会按最新的工作区归属重新分组(移动会话后使用)。',
32
38
  loading: '正在读取会话…',
33
39
  loadFailed: '读取会话失败:{message}',
34
40
  retry: '重试',
@@ -58,6 +64,15 @@ window.__ModuleLoader__.load({
58
64
  loadMore: '加载更多(剩余 {n} 条)',
59
65
  selectionScopeHint: '选择仅作用于当前筛选与展开结果。',
60
66
  searchAutoExpand: '搜索时自动展开命中工作区。',
67
+ groupLoading: '正在加载会话…',
68
+ groupLoadFailed: '加载失败:{message}',
69
+ groupRetry: '重试',
70
+ loadedOf: '已加载 {loaded}/{total}',
71
+ partialSelection: '另有 {n} 条未加载,未纳入全选',
72
+ parentNotLoaded: '来源父会话未加载',
73
+ overviewLoadFailed: '读取总览失败:{message}',
74
+ sizesPending: '正在统计大小…',
75
+ sizesUnavailable: '大小统计暂不可用,请稍后重试。',
61
76
  selectRow: '选择会话 {name}',
62
77
  justNow: '刚刚',
63
78
  titleApplied: '标题已写入。',
@@ -160,6 +175,7 @@ window.__ModuleLoader__.load({
160
175
  workspaceMissingErr: '目标工作区目录不存在。',
161
176
  notFound: '会话或工作区不存在。',
162
177
  batchDone: '已处理:成功 {ok},失败 {failed}。',
178
+ movedWithFamily: '连同 {n} 个子代理会话一起移动',
163
179
  batchFailed: '有 {failed} 个操作失败。',
164
180
  failures: '失败详情',
165
181
  detailsToggle: '展开详情',
@@ -195,6 +211,8 @@ window.__ModuleLoader__.load({
195
211
  pageTitle: 'Session Manager',
196
212
  pageSubtitle: 'Manage every session in this profile: titles, archive, move, and permanent deletion.',
197
213
  refresh: 'Refresh',
214
+ reloadPage: 'Reload page',
215
+ reloadPageHint: 'Reload the whole DSH page so the sidebar regroups sessions by their current workspace (use it after a move).',
198
216
  loading: 'Loading sessions…',
199
217
  loadFailed: 'Could not load sessions: {message}',
200
218
  retry: 'Retry',
@@ -224,6 +242,15 @@ window.__ModuleLoader__.load({
224
242
  loadMore: 'Load {n} more',
225
243
  selectionScopeHint: 'Selection applies to the current filter and expanded workspaces.',
226
244
  searchAutoExpand: 'Matching workspaces expand automatically while searching.',
245
+ groupLoading: 'Loading sessions…',
246
+ groupLoadFailed: 'Could not load: {message}',
247
+ groupRetry: 'Retry',
248
+ loadedOf: '{loaded}/{total} loaded',
249
+ partialSelection: '{n} more not loaded; not included in Select all',
250
+ parentNotLoaded: 'Source session not loaded',
251
+ overviewLoadFailed: 'Could not load the overview: {message}',
252
+ sizesPending: 'Measuring sizes…',
253
+ sizesUnavailable: 'Size totals are not ready; try again in a moment.',
227
254
  selectRow: 'Select session {name}',
228
255
  justNow: 'just now',
229
256
  titleApplied: 'Title applied.',
@@ -326,6 +353,7 @@ window.__ModuleLoader__.load({
326
353
  workspaceMissingErr: 'The target workspace directory is missing.',
327
354
  notFound: 'The session or workspace was not found.',
328
355
  batchDone: 'Processed: {ok} succeeded, {failed} failed.',
356
+ movedWithFamily: 'moved together with {n} subagent session(s)',
329
357
  batchFailed: '{failed} operations failed.',
330
358
  failures: 'Failure details',
331
359
  detailsToggle: 'Show details',
@@ -430,6 +458,7 @@ window.__ModuleLoader__.load({
430
458
  .dsm-group-title{font-size:12px;font-weight:600;line-height:18px}
431
459
  .dsm-group-path{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);word-break:break-all}
432
460
  .dsm-group-meta{margin-left:auto;font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
461
+ .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}
433
462
  .dsm-warn{color:var(--dsw-alias-state-warn-primary);font-size:11px}
434
463
  .dsm-row{display:grid;grid-template-columns:18px minmax(0,1fr) auto auto;align-items:center;gap:8px;
435
464
  padding:7px 10px;border-top:1px solid var(--dsw-alias-border-l1);min-width:0}
@@ -597,6 +626,7 @@ window.__ModuleLoader__.load({
597
626
  : t('noAdapterUnknown')
598
627
  }
599
628
  if (code === 'title-invalid') return t('titleInvalid')
629
+ if (code === 'sizes-pending') return t('sizesUnavailable')
600
630
  if (code === 'timeout') return t('timeout')
601
631
  if (code === 'llm-error') return t('llmError', { message: error?.message ?? '' })
602
632
  if (code === 'session-active') return t('sessionActive')
@@ -638,6 +668,26 @@ window.__ModuleLoader__.load({
638
668
  const getState = (includeSubagents, signal) =>
639
669
  request('GET', `/state?subagents=${includeSubagents ? '1' : '0'}`, undefined, signal)
640
670
 
671
+ const getOverview = (includeSubagents, signal) =>
672
+ request('GET', `/overview?subagents=${includeSubagents ? '1' : '0'}`, undefined, signal)
673
+
674
+ const getBlanks = (includeSubagents, signal) =>
675
+ request('GET', `/blanks?subagents=${includeSubagents ? '1' : '0'}`, undefined, signal)
676
+
677
+ const getSizes = (workspaceId, includeSubagents, signal) =>
678
+ request(
679
+ 'GET',
680
+ `/sizes?workspaceId=${encodeURIComponent(workspaceId)}&subagents=${includeSubagents ? '1' : '0'}`,
681
+ undefined,
682
+ signal,
683
+ )
684
+
685
+ const getSessions = (params, signal) => request('GET', `/sessions?${queryOf(params)}`, undefined, signal)
686
+
687
+ const getSearch = (params, signal) => request('GET', `/search?${queryOf(params)}`, undefined, signal)
688
+
689
+ const getSession = (id, signal) => request('GET', `/session?id=${encodeURIComponent(id)}`, undefined, signal)
690
+
641
691
  const post = (action, body, signal) => request('POST', `/${action}`, body, signal)
642
692
 
643
693
  function useLocaleTick(ctx) {
@@ -674,26 +724,30 @@ window.__ModuleLoader__.load({
674
724
  /** How long a plain success toast stays before hiding itself. */
675
725
  const NOTICE_HIDE_MS = 5000
676
726
 
727
+ /** A success toast offering one follow-up action stays a little longer. */
728
+ const NOTICE_HIDE_ACTION_MS = 8000
729
+
677
730
  /**
678
731
  * Operation-result toast.
679
732
  *
680
733
  * It floats above the settings pane (instead of sitting in the scroll flow),
681
- * so a result is visible no matter where the list is scrolled. A plain
682
- * success toast hides itself after a few seconds; failures and notices with
683
- * an action stay until the user dismisses them, because they carry details
684
- * worth reading.
734
+ * so a result is visible no matter where the list is scrolled. A success
735
+ * toast hides itself after a few seconds, with or without a follow-up
736
+ * action; failures and warnings stay until the user dismisses them, because
737
+ * they carry details worth reading.
685
738
  */
686
739
  function Notice({ notice, t, onDismiss }) {
687
740
  const dismiss = React.useRef(onDismiss)
688
741
  dismiss.current = onDismiss
689
742
  const transient = notice !== null && notice !== undefined
690
- && notice.tone !== 'error' && notice.tone !== 'warn'
691
- && notice.details === undefined && notice.action === undefined
743
+ && notice.details === undefined
744
+ && (notice.tone === 'ok' || (notice.tone !== 'error' && notice.tone !== 'warn' && notice.action === undefined))
745
+ const delay = notice?.action === undefined ? NOTICE_HIDE_MS : NOTICE_HIDE_ACTION_MS
692
746
  React.useEffect(() => {
693
747
  if (!transient) return undefined
694
- const timer = setTimeout(() => dismiss.current?.(), NOTICE_HIDE_MS)
748
+ const timer = setTimeout(() => dismiss.current?.(), delay)
695
749
  return () => clearTimeout(timer)
696
- }, [notice, transient])
750
+ }, [notice, transient, delay])
697
751
  if (notice === null || notice === undefined) return null
698
752
  return h(
699
753
  'div',
@@ -723,7 +777,21 @@ window.__ModuleLoader__.load({
723
777
  function Row({ row, selected, onToggle, onOpen, t }) {
724
778
  const depth = row.branchDepth ?? 0
725
779
  const branch = row.kind === 'fork'
780
+ const glyph = depth > 0 || row.branchOrphan === true
726
781
  const forkCount = row.forkCount ?? 0
782
+ // A branch whose parent is not among the loaded rows keeps the glyph but
783
+ // must not claim the parent is gone: the three cases read differently.
784
+ const parentNote = branch !== true
785
+ ? ''
786
+ : row.branchParentTitle
787
+ ? `${t('fromParent', { title: row.branchParentTitle })} · `
788
+ : row.branchParentMissing
789
+ ? `${t('parentMissing')} · `
790
+ : row.branchParentNotLoaded
791
+ ? `${t('parentNotLoaded')} · `
792
+ : row.branchDepth === undefined && row.parentPresent === false
793
+ ? `${t('parentMissing')} · `
794
+ : ''
727
795
  const badges = []
728
796
  if (row.running) badges.push(h(Badge, { key: 'run', tone: 'run' }, t('running')))
729
797
  if (row.archived) badges.push(h(Badge, { key: 'arch' }, t('archived')))
@@ -755,17 +823,13 @@ window.__ModuleLoader__.load({
755
823
  h(
756
824
  'span',
757
825
  { className: 'dsm-row-title' },
758
- depth > 0 ? h('span', { className: 'dsm-branch-glyph', 'aria-hidden': 'true' }, '↳') : null,
826
+ glyph ? h('span', { className: 'dsm-branch-glyph', 'aria-hidden': 'true' }, '↳') : null,
759
827
  row.title === '' ? t('untitled') : row.title,
760
828
  ),
761
829
  h(
762
830
  'span',
763
831
  { className: 'dsm-row-meta' },
764
- branch && row.parentTitle !== ''
765
- ? `${t('fromParent', { title: row.parentTitle })} · `
766
- : branch && row.parentPresent === false
767
- ? `${t('parentMissing')} · `
768
- : '',
832
+ parentNote,
769
833
  row.cwd === '' ? t('noCwd') : row.cwd,
770
834
  ' · ',
771
835
  row.id,
@@ -781,8 +845,15 @@ window.__ModuleLoader__.load({
781
845
  )
782
846
  }
783
847
 
784
- function Group({ group, selected, onToggle, onSelect, onOpen, onMore, t, locked }) {
785
- const remaining = group.rows.length - group.renderedRows.length
848
+ function Group({ group, selected, onToggle, onSelect, onOpen, onMore, onRetry, t, locked }) {
849
+ const total = Number.isFinite(group.total) ? group.total : group.rows.length
850
+ // Paged groups render everything they have loaded; the legacy fallback
851
+ // renders a 0.1.x-style 50-row window per group.
852
+ const rendered = Array.isArray(group.renderedRows) ? group.renderedRows : group.rows
853
+ const remaining = Math.max(0, total - rendered.length)
854
+ const size = formatBytesOrDash(group.bytes)
855
+ const meta = `${t('groupLine', { n: total, size })}${group.expanded === true && remaining > 0 ? ` · ${t('loadedOf', { loaded: rendered.length, total })}` : ''}`
856
+ const empty = rendered.length === 0
786
857
  return h(
787
858
  'section',
788
859
  { className: 'dsm-group' },
@@ -815,19 +886,30 @@ window.__ModuleLoader__.load({
815
886
  ? h('div', { className: 'dsm-warn' }, t('workspaceMissing'))
816
887
  : null,
817
888
  ),
818
- h('div', { className: 'dsm-group-meta' }, t('groupLine', { n: group.rows.length, size: formatBytes(group.bytes) })),
819
- ),
820
- group.renderedRows.map((row) =>
821
- h(Row, {
822
- key: row.id,
823
- row,
824
- selected: selected.has(row.id),
825
- onToggle: () => onSelect(row.id),
826
- onOpen: () => onOpen(row.id),
827
- t,
828
- }),
889
+ h('div', { className: 'dsm-group-meta' }, meta),
829
890
  ),
830
- group.expanded === true && remaining > 0
891
+ group.expanded === true && empty && (group.phase === 'loading' || group.phase === 'idle')
892
+ ? h('p', { className: 'dsm-group-loaded dsm-hint' }, t('groupLoading'))
893
+ : null,
894
+ group.expanded === true && group.phase === 'error'
895
+ ? h('div', { className: 'dsm-group-loaded' },
896
+ h('span', { className: 'dsm-error' }, t('groupLoadFailed', { message: errorText(t, group.error) })),
897
+ h('button', { type: 'button', className: 'dsm-btn', onClick: onRetry }, t('groupRetry')),
898
+ )
899
+ : null,
900
+ group.expanded === true
901
+ ? rendered.map((row) =>
902
+ h(Row, {
903
+ key: row.id,
904
+ row,
905
+ selected: selected.has(row.id),
906
+ onToggle: () => onSelect(row.id),
907
+ onOpen: () => onOpen(row.id),
908
+ t,
909
+ }),
910
+ )
911
+ : null,
912
+ group.expanded === true && remaining > 0 && group.phase === 'ready'
831
913
  ? h(
832
914
  'button',
833
915
  { type: 'button', className: 'dsm-btn ghost dsm-more', onClick: onMore },
@@ -1172,12 +1254,12 @@ window.__ModuleLoader__.load({
1172
1254
  // Detail view
1173
1255
  // ---------------------------------------------------------------------
1174
1256
 
1175
- function DetailView({ ctx, t, state, row, busy, onBack, onChanged, onOpen, onArchive, onRestore, onMove, onDelete }) {
1257
+ function DetailView({ ctx, t, overview, limits, row, busy, onBack, onChanged, onOpen, onArchive, onRestore, onMove, onDelete }) {
1176
1258
  const [copied, setCopied] = React.useState(false)
1177
1259
  const [showDelete, setShowDelete] = React.useState(false)
1178
1260
  const deleteIds = React.useMemo(() => [row.id], [row.id])
1179
1261
  const [target, setTarget] = React.useState('')
1180
- const targets = (state?.workspaces ?? []).filter((workspace) => workspace.id !== row.workspaceId && workspace.status === 'ok')
1262
+ const targets = (overview?.workspaces ?? []).filter((workspace) => workspace.id !== row.workspaceId && workspace.status === 'ok')
1181
1263
  const movable = row.can?.move === true && targets.length > 0
1182
1264
  const uiWorkspace = (() => {
1183
1265
  try {
@@ -1324,7 +1406,7 @@ window.__ModuleLoader__.load({
1324
1406
  ctx,
1325
1407
  t,
1326
1408
  ids: deleteIds,
1327
- maxBatch: state.limits?.maxBatch ?? 50,
1409
+ maxBatch: limits?.maxBatch ?? 50,
1328
1410
  busy,
1329
1411
  onCancel: () => setShowDelete(false),
1330
1412
  onConfirmed: (ids) => onDelete(ids),
@@ -1370,16 +1452,11 @@ window.__ModuleLoader__.load({
1370
1452
  return out.length === rows.length ? out : rows
1371
1453
  }
1372
1454
 
1373
- /**
1374
- * Derive the filtered / grouped / rendered view for the list.
1375
- * Pure so the selection-pruning effect can use it before the loading guard.
1376
- */
1377
- function computeView({ state, tab, workspaceFilter, query, sort, expanded, defaultExpanded, limits, showBlank }) {
1378
- const rows = state?.sessions ?? []
1379
- const workspaceOrder = state?.workspaces ?? []
1380
- const text = query.trim().toLowerCase()
1455
+ /** Tab / workspace / blank / text filter over one state's rows. */
1456
+ function filterRows(rows, { tab, workspaceFilter, query, showBlank }) {
1457
+ const text = (query ?? '').trim().toLowerCase()
1381
1458
  const filtering = text !== ''
1382
- const filtered = rows.filter((row) => {
1459
+ return rows.filter((row) => {
1383
1460
  if (tab === 'active' && row.archived) return false
1384
1461
  if (tab === 'archived' && !row.archived) return false
1385
1462
  if (workspaceFilter === 'ungrouped' && row.workspaceId !== null) return false
@@ -1391,20 +1468,37 @@ window.__ModuleLoader__.load({
1391
1468
  }
1392
1469
  return true
1393
1470
  })
1394
- const sorted = [...filtered].sort((left, right) => {
1395
- if (sort === 'created') return right.createdAt - left.createdAt
1396
- if (sort === 'size') return right.sizeBytes - left.sizeBytes
1397
- if (sort === 'title') return (left.title || left.cwd).localeCompare(right.title || right.cwd)
1398
- return right.updatedAt - left.updatedAt
1471
+ }
1472
+
1473
+ /** Id order used as the tie-break of every sort so paged slices stay stable. */
1474
+ function compareRowIds(left, right) {
1475
+ if (left.id === right.id) return 0
1476
+ return left.id < right.id ? -1 : 1
1477
+ }
1478
+
1479
+ /** Sort a filtered row list; equal keys fall back to id ascending. */
1480
+ function sortRows(rows, sort) {
1481
+ return [...rows].sort((left, right) => {
1482
+ let order
1483
+ if (sort === 'created') order = right.createdAt - left.createdAt
1484
+ else if (sort === 'size') order = right.sizeBytes - left.sizeBytes
1485
+ else if (sort === 'title') order = (left.title || left.cwd).localeCompare(right.title || right.cwd)
1486
+ else order = right.updatedAt - left.updatedAt
1487
+ // `!order` also catches NaN from a missing numeric field.
1488
+ return !order ? compareRowIds(left, right) : order
1399
1489
  })
1490
+ }
1491
+
1492
+ /** Group sorted rows in workspace order, keeping the ungrouped group last. */
1493
+ function groupRows(sorted, workspaceOrder) {
1400
1494
  const baseGroups = []
1401
1495
  for (const workspace of workspaceOrder) {
1402
- const groupRows = orderBranchRows(sorted.filter((row) => row.workspaceId === workspace.id))
1403
- if (groupRows.length === 0) continue
1496
+ const rows = orderBranchRows(sorted.filter((row) => row.workspaceId === workspace.id))
1497
+ if (rows.length === 0) continue
1404
1498
  baseGroups.push({
1405
1499
  ...workspace,
1406
- rows: groupRows,
1407
- bytes: groupRows.reduce((sum, row) => sum + row.sizeBytes, 0),
1500
+ rows,
1501
+ bytes: rows.reduce((sum, row) => sum + row.sizeBytes, 0),
1408
1502
  })
1409
1503
  }
1410
1504
  const ungroupedRows = orderBranchRows(sorted.filter((row) => row.workspaceId === null))
@@ -1417,7 +1511,12 @@ window.__ModuleLoader__.load({
1417
1511
  bytes: ungroupedRows.reduce((sum, row) => sum + row.sizeBytes, 0),
1418
1512
  })
1419
1513
  }
1420
- const groups = baseGroups.map((group) => {
1514
+ return baseGroups
1515
+ }
1516
+
1517
+ /** Attach expansion + page-limit state to each group; only expanded groups render rows. */
1518
+ function expandGroups(baseGroups, { expanded, defaultExpanded, limits, filtering }) {
1519
+ return baseGroups.map((group) => {
1421
1520
  const isExpanded = filtering ? true : (expanded === null ? defaultExpanded : expanded.has(group.id))
1422
1521
  const limit = limits[group.id] ?? GROUP_PAGE_SIZE
1423
1522
  return {
@@ -1427,6 +1526,47 @@ window.__ModuleLoader__.load({
1427
1526
  renderedRows: isExpanded ? group.rows.slice(0, limit) : [],
1428
1527
  }
1429
1528
  })
1529
+ }
1530
+
1531
+ /**
1532
+ * Keep only the selected ids that are still visible. Returning the previous
1533
+ * Set when nothing is dropped keeps the React state identity stable.
1534
+ */
1535
+ function pruneSelection(selected, visibleIdSet) {
1536
+ if (selected.size === 0) return selected
1537
+ const next = new Set()
1538
+ for (const id of selected) if (visibleIdSet.has(id)) next.add(id)
1539
+ return next.size === selected.size ? selected : next
1540
+ }
1541
+
1542
+ /**
1543
+ * Key of every scope-dependent client state (selection pruning, page
1544
+ * limits): it must change whenever a filter *or the sort* changes.
1545
+ */
1546
+ function scopeKey({ generatedAt, tab, workspaceFilter, query, sort, includeSubagents, showBlank, defaultExpanded, expanded }) {
1547
+ return [
1548
+ generatedAt ?? 0,
1549
+ tab,
1550
+ workspaceFilter,
1551
+ query,
1552
+ sort,
1553
+ includeSubagents ? 1 : 0,
1554
+ showBlank ? 1 : 0,
1555
+ defaultExpanded ? 1 : 0,
1556
+ expanded === null || expanded === undefined ? 'auto' : [...expanded].sort().join(','),
1557
+ ].join('|')
1558
+ }
1559
+
1560
+ /**
1561
+ * Derive the filtered / grouped / rendered view for the list.
1562
+ * Pure so the selection-pruning effect can use it before the loading guard.
1563
+ */
1564
+ function computeView({ state, tab, workspaceFilter, query, sort, expanded, defaultExpanded, limits, showBlank }) {
1565
+ const rows = state?.sessions ?? []
1566
+ const workspaceOrder = state?.workspaces ?? []
1567
+ const filtering = query.trim() !== ''
1568
+ const sorted = sortRows(filterRows(rows, { tab, workspaceFilter, query, showBlank }), sort)
1569
+ const groups = expandGroups(groupRows(sorted, workspaceOrder), { expanded, defaultExpanded, limits, filtering })
1430
1570
  const visibleRows = groups.flatMap((group) => (group.expanded ? group.rows : []))
1431
1571
  return {
1432
1572
  filteredRows: sorted,
@@ -1436,87 +1576,704 @@ window.__ModuleLoader__.load({
1436
1576
  }
1437
1577
  }
1438
1578
 
1579
+ // ---------------------------------------------------------------------
1580
+ // Paged data layer helpers (pure; the page only schedules requests)
1581
+ // ---------------------------------------------------------------------
1582
+
1583
+ /** True when a request failure means this Host build has no such route. */
1584
+ function isMissingRoute(failure) {
1585
+ return failure?.status === 404 || failure?.code === 'http-404' || failure?.code === 'not-found'
1586
+ }
1587
+
1588
+ /** Encode a flat parameter object as a query string, skipping empty values. */
1589
+ function queryOf(params) {
1590
+ const parts = []
1591
+ for (const key of Object.keys(params ?? {})) {
1592
+ const value = params[key]
1593
+ if (value === undefined || value === null || value === '') continue
1594
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
1595
+ }
1596
+ return parts.join('&')
1597
+ }
1598
+
1599
+ /** The six-field scope key that invalidates page caches and selection. */
1600
+ function dataScopeKey({ tab, workspaceFilter, query, sort, includeSubagents, showBlank }) {
1601
+ return [tab, workspaceFilter, query, sort, includeSubagents ? 1 : 0, showBlank ? 1 : 0].join('|')
1602
+ }
1603
+
1604
+ /** Append one page to the loaded rows, skipping ids already on screen. */
1605
+ function mergePageRows(current, incoming) {
1606
+ if (!Array.isArray(incoming) || incoming.length === 0) return current
1607
+ const seen = new Set(current.map((row) => row.id))
1608
+ const out = [...current]
1609
+ for (const row of incoming) {
1610
+ if (seen.has(row.id)) continue
1611
+ seen.add(row.id)
1612
+ out.push(row)
1613
+ }
1614
+ return out
1615
+ }
1616
+
1617
+ /** Merge rows into the id → row cache; later responses win. */
1618
+ function mergeRowCache(cache, rows) {
1619
+ if (!Array.isArray(rows) || rows.length === 0) return cache
1620
+ const next = new Map(cache)
1621
+ for (const row of rows) next.set(row.id, row)
1622
+ return next
1623
+ }
1624
+
1625
+ /**
1626
+ * Nest one group's loaded rows and annotate branch rows whose parent is not
1627
+ * among them: such a row stays top level but keeps the "↳" glyph, and its
1628
+ * meta line distinguishes "not loaded", "no longer exists", and "from X".
1629
+ */
1630
+ function annotateBranchRows(rows) {
1631
+ const ordered = orderBranchRows(rows)
1632
+ const ids = new Set(rows.map((row) => row.id))
1633
+ return ordered.map((row) => {
1634
+ if (row.kind !== 'fork') return row
1635
+ const parentLoaded = row.parentSessionId !== null && ids.has(row.parentSessionId)
1636
+ if (parentLoaded) {
1637
+ return { ...row, branchParentTitle: row.parentTitle || '', branchParentMissing: false, branchParentNotLoaded: false }
1638
+ }
1639
+ const stub = row.parentStub
1640
+ const stubKnown = stub !== null && stub !== undefined
1641
+ const present = stubKnown ? stub.present === true : row.parentPresent !== false
1642
+ const stubTitle = stubKnown && typeof stub.title === 'string' ? stub.title : ''
1643
+ const title = stubTitle !== '' ? stubTitle : row.parentTitle || ''
1644
+ if (present !== true) {
1645
+ return { ...row, branchDepth: 0, branchOrphan: true, branchParentTitle: '', branchParentMissing: true, branchParentNotLoaded: false }
1646
+ }
1647
+ if (title !== '') {
1648
+ return { ...row, branchDepth: 0, branchOrphan: true, branchParentTitle: title, branchParentMissing: false, branchParentNotLoaded: false }
1649
+ }
1650
+ return { ...row, branchDepth: 0, branchOrphan: true, branchParentTitle: '', branchParentMissing: false, branchParentNotLoaded: true }
1651
+ })
1652
+ }
1653
+
1654
+ /** A `/state` payload expressed in the `/overview` shape (old-Host fallback). */
1655
+ function overviewFromState(state) {
1656
+ const rows = Array.isArray(state?.sessions) ? state.sessions : []
1657
+ const workspaces = Array.isArray(state?.workspaces) ? state.workspaces : []
1658
+ const bucketOf = (subset) => ({
1659
+ count: subset.length,
1660
+ activeCount: subset.filter((row) => !row.archived).length,
1661
+ archivedCount: subset.filter((row) => row.archived).length,
1662
+ blankCount: subset.filter((row) => row.blank === true).length,
1663
+ })
1664
+ const totals = state?.totals ?? {}
1665
+ const ungroupedRows = rows.filter((row) => row.workspaceId === null)
1666
+ return {
1667
+ generatedAt: state?.generatedAt ?? 0,
1668
+ source: 'legacy',
1669
+ backend: state?.backend,
1670
+ capabilities: state?.capabilities ?? {},
1671
+ limits: state?.limits ?? {},
1672
+ workspaces: workspaces.map((workspace) => ({
1673
+ ...workspace,
1674
+ ...bucketOf(rows.filter((row) => row.workspaceId === workspace.id)),
1675
+ totalBytes: null,
1676
+ })),
1677
+ ungrouped: { ...bucketOf(ungroupedRows), totalBytes: null },
1678
+ totals: {
1679
+ all: totals.all ?? rows.length,
1680
+ active: totals.active ?? rows.filter((row) => !row.archived).length,
1681
+ archived: totals.archived ?? rows.filter((row) => row.archived).length,
1682
+ subagents: rows.filter((row) => row.isSubagent === true).length,
1683
+ hiddenSubagents: totals.hiddenSubagents ?? 0,
1684
+ forks: rows.filter((row) => row.kind === 'fork').length,
1685
+ blank: rows.filter((row) => row.blank === true).length,
1686
+ bytes: totals.bytes ?? null,
1687
+ },
1688
+ orphans: Array.isArray(state?.orphans) ? state.orphans : [],
1689
+ }
1690
+ }
1691
+
1692
+ /** The three blank-session id lists, derived from one `/state` payload. */
1693
+ function blanksFromState(state) {
1694
+ const rows = Array.isArray(state?.sessions) ? state.sessions : []
1695
+ const blank = rows.filter((row) => row.blank === true)
1696
+ return {
1697
+ unarchivedIds: blank.filter((row) => !row.archived).map((row) => row.id),
1698
+ archivedIds: blank.filter((row) => row.archived).map((row) => row.id),
1699
+ deletableIds: blank.filter((row) => row.archived && row.can?.delete === true).map((row) => row.id),
1700
+ }
1701
+ }
1702
+
1703
+ function bytesOf(value) {
1704
+ if (value === null || value === undefined) return null
1705
+ const number = Number(value)
1706
+ return Number.isFinite(number) ? number : null
1707
+ }
1708
+
1709
+ /**
1710
+ * Normalize the `/sizes` payload:
1711
+ * `{total:{bytes,count}, workspaces:[{id,bytes,count}], ungrouped:{bytes,count}}`.
1712
+ * The short aliases keep a degraded or older payload usable.
1713
+ */
1714
+ function normalizeSizes(value) {
1715
+ const byWorkspace = new Map()
1716
+ const list = Array.isArray(value) ? value : Array.isArray(value?.workspaces) ? value.workspaces : []
1717
+ for (const item of list) {
1718
+ if (item === null || typeof item !== 'object') continue
1719
+ const id = typeof item.id === 'string' ? item.id : typeof item.workspaceId === 'string' ? item.workspaceId : ''
1720
+ if (id === '') continue
1721
+ const bytes = bytesOf(item.totalBytes ?? item.bytes ?? item.sizeBytes)
1722
+ if (bytes !== null) byWorkspace.set(id, bytes)
1723
+ }
1724
+ const ungroupedBytes = bytesOf(value?.ungrouped?.bytes ?? value?.ungrouped?.totalBytes)
1725
+ if (ungroupedBytes !== null) byWorkspace.set('ungrouped', ungroupedBytes)
1726
+ const totalSource = value?.totalBytes ?? value?.bytes ?? value?.total
1727
+ const totalBytes = totalSource !== null && typeof totalSource === 'object'
1728
+ ? bytesOf(totalSource.bytes ?? totalSource.totalBytes ?? totalSource.sizeBytes)
1729
+ : bytesOf(totalSource)
1730
+ return { totalBytes, byWorkspace }
1731
+ }
1732
+
1733
+ function formatBytesOrDash(bytes) {
1734
+ return Number.isFinite(bytes) && bytes >= 0 ? formatBytes(bytes) : '—'
1735
+ }
1736
+
1737
+ /** Count one overview bucket for a tab. */
1738
+ function countBucket(bucket, tab) {
1739
+ if (bucket === null || bucket === undefined) return 0
1740
+ if (tab === 'archived') return bucket.archivedCount ?? 0
1741
+ if (tab === 'active') return bucket.activeCount ?? 0
1742
+ return bucket.count ?? 0
1743
+ }
1744
+
1745
+ /**
1746
+ * Compose the rendered groups from the overview plus the loaded page cache.
1747
+ * Pure: the page component only memoizes it. `searchMode` keeps only groups
1748
+ * the search answered for; `requirePage` does the same for the legacy
1749
+ * fallback, whose pages are built locally.
1750
+ */
1751
+ function composeGroups({ overview, pages, sizes, expanded, defaultExpanded, query, tab, workspaceFilter, showBlank, searchMode, requirePage, legacy, legacyLimits }) {
1752
+ const filtering = typeof query === 'string' && query.trim() !== ''
1753
+ const isExpanded = (id) => (filtering ? true : expanded === null ? defaultExpanded : expanded.has(id))
1754
+ const workspaces = Array.isArray(overview?.workspaces) ? overview.workspaces : []
1755
+ const workspaceById = new Map(workspaces.map((workspace) => [workspace.id, workspace]))
1756
+ const ids = []
1757
+ for (const workspace of workspaces) {
1758
+ if (workspaceFilter !== 'all' && workspace.id !== workspaceFilter) continue
1759
+ ids.push(workspace.id)
1760
+ }
1761
+ const ungrouped = overview?.ungrouped
1762
+ if ((workspaceFilter === 'all' || workspaceFilter === 'ungrouped') && (ungrouped?.count ?? 0) > 0) ids.push('ungrouped')
1763
+ const groups = []
1764
+ for (const id of ids) {
1765
+ // Structural checks: these maps may come from another realm in tests.
1766
+ const page = pages !== null && pages !== undefined && typeof pages.get === 'function' ? pages.get(id) : undefined
1767
+ if ((searchMode === true || requirePage === true) && page === undefined) continue
1768
+ const meta = id === 'ungrouped'
1769
+ ? { title: '', path: '', status: 'ok' }
1770
+ : workspaceById.get(id) ?? { title: '', path: '', status: 'ok' }
1771
+ let fallbackCount = id === 'ungrouped' ? countBucket(ungrouped, tab) : countBucket(meta, tab)
1772
+ if (showBlank !== true && tab === 'all') {
1773
+ fallbackCount -= id === 'ungrouped' ? ungrouped?.blankCount ?? 0 : meta.blankCount ?? 0
1774
+ }
1775
+ if (fallbackCount < 0) fallbackCount = 0
1776
+ if (page === undefined && fallbackCount <= 0) continue
1777
+ if (page !== undefined && page.phase === 'ready' && (page.total ?? 0) === 0) continue
1778
+ const rows = annotateBranchRows(Array.isArray(page?.rows) ? page.rows : [])
1779
+ // The legacy fallback has every row in memory but keeps the 0.1.x
1780
+ // per-group window: expand to 50, "load more" grows it by 50.
1781
+ const windowSize = legacy === true ? legacyLimits?.[id] ?? GROUP_PAGE_SIZE : rows.length
1782
+ groups.push({
1783
+ id,
1784
+ title: meta.title ?? '',
1785
+ path: meta.path ?? '',
1786
+ status: meta.status,
1787
+ rows,
1788
+ renderedRows: isExpanded(id) ? rows.slice(0, windowSize) : [],
1789
+ expanded: isExpanded(id),
1790
+ total: page !== undefined && Number.isFinite(page.total) ? page.total : fallbackCount,
1791
+ hasMore: page?.hasMore === true,
1792
+ phase: page?.phase ?? 'idle',
1793
+ error: page?.error ?? null,
1794
+ bytes: sizes !== null && sizes !== undefined && typeof sizes.byWorkspace?.get === 'function' ? sizes.byWorkspace.get(id) ?? null : null,
1795
+ })
1796
+ }
1797
+ const visibleRows = groups.flatMap((group) => (group.expanded === true ? group.rows : []))
1798
+ let partialCount = 0
1799
+ for (const group of groups) {
1800
+ if (group.expanded !== true) continue
1801
+ partialCount += Math.max(0, (group.total ?? 0) - group.rows.length)
1802
+ }
1803
+ return {
1804
+ groups,
1805
+ visibleRows,
1806
+ visibleIdSet: new Set(visibleRows.map((row) => row.id)),
1807
+ partialCount,
1808
+ }
1809
+ }
1810
+
1811
+ /** Run scheduled page loads with a small concurrency ceiling. */
1812
+ function pumpLimiter(state) {
1813
+ while (state.active < MAX_CONCURRENT_PAGES && state.queue.length > 0) {
1814
+ const task = state.queue.shift()
1815
+ state.active += 1
1816
+ Promise.resolve()
1817
+ .then(task)
1818
+ .catch(() => {})
1819
+ .then(() => {
1820
+ state.active -= 1
1821
+ pumpLimiter(state)
1822
+ })
1823
+ }
1824
+ }
1825
+
1439
1826
  function SessionManagerPage(props) {
1440
1827
  const { ctx, t } = props
1441
1828
  useLocaleTick(ctx)
1442
- const [state, setState] = React.useState(null)
1443
- const [phase, setPhase] = React.useState('loading')
1444
- const [error, setError] = React.useState(null)
1445
1829
  const [tab, setTab] = React.useState('all')
1446
1830
  const [workspaceFilter, setWorkspaceFilter] = React.useState('all')
1831
+ const [queryInput, setQueryInput] = React.useState('')
1447
1832
  const [query, setQuery] = React.useState('')
1448
1833
  const [sort, setSort] = React.useState('updated')
1449
1834
  const [includeSubagents, setIncludeSubagents] = React.useState(false)
1835
+ const [showBlank, setShowBlank] = React.useState(false)
1836
+ const [expanded, setExpanded] = React.useState(null)
1450
1837
  const [selected, setSelected] = React.useState(() => new Set())
1451
1838
  const [selectedId, setSelectedId] = React.useState(null)
1452
1839
  const [busy, setBusy] = React.useState(false)
1453
1840
  const [notice, setNotice] = React.useState(null)
1454
1841
  const [bulkDeleteIds, setBulkDeleteIds] = React.useState(null)
1455
1842
  const [bulkTarget, setBulkTarget] = React.useState('')
1456
- const [expanded, setExpanded] = React.useState(null)
1457
- const [limits, setLimits] = React.useState({})
1458
- const [showBlank, setShowBlank] = React.useState(false)
1459
- const abortRef = React.useRef(null)
1843
+ // Two-stage data layer: `overview` is the first paint, `pages` holds one
1844
+ // lazily loaded page box per workspace, `rowCache` is the only row source
1845
+ // for the detail view and for batch submissions.
1846
+ const [transport, setTransport] = React.useState('loading')
1847
+ const [overview, setOverview] = React.useState(null)
1848
+ const [overviewPhase, setOverviewPhase] = React.useState('loading')
1849
+ const [overviewError, setOverviewError] = React.useState(null)
1850
+ const [legacyState, setLegacyState] = React.useState(null)
1851
+ const [pages, setPages] = React.useState(() => new Map())
1852
+ const [rowCache, setRowCache] = React.useState(() => new Map())
1853
+ const [blanks, setBlanks] = React.useState(null)
1854
+ const [sizes, setSizes] = React.useState(null)
1855
+ const [sizesPhase, setSizesPhase] = React.useState('idle')
1856
+ const [searchPhase, setSearchPhase] = React.useState('idle')
1857
+ const [searchError, setSearchError] = React.useState(null)
1858
+ const [reload, setReload] = React.useState(0)
1859
+ // Legacy fallback only: per-group row window (0.1.x "load more").
1860
+ const [legacyLimits, setLegacyLimits] = React.useState({})
1861
+ const [detail, setDetail] = React.useState(null)
1862
+ const [detailPhase, setDetailPhase] = React.useState('idle')
1863
+ const [detailError, setDetailError] = React.useState(null)
1864
+ const [detailNonce, setDetailNonce] = React.useState(0)
1460
1865
  const searchRef = React.useRef(null)
1461
1866
  const listScroll = React.useRef(0)
1867
+ const overviewAbort = React.useRef(null)
1868
+ const sizesAbort = React.useRef(null)
1869
+ const searchAbort = React.useRef(null)
1870
+ const detailAbort = React.useRef(null)
1871
+ const pagesEpoch = React.useRef(0)
1872
+ const pageControllers = React.useRef(new Map())
1873
+ const inflightPages = React.useRef(new Set())
1874
+ const limiter = React.useRef({ active: 0, queue: [] })
1875
+ const preserveCounts = React.useRef(new Map())
1876
+ const preserveChain = React.useRef(new Set())
1877
+ const transportRef = React.useRef('loading')
1878
+ const overviewRef = React.useRef(null)
1879
+ const legacyStateRef = React.useRef(null)
1880
+ const pagesRef = React.useRef(pages)
1881
+ const rowCacheRef = React.useRef(rowCache)
1882
+ const tabRef = React.useRef(tab)
1883
+ const sortRef = React.useRef(sort)
1884
+ const queryRef = React.useRef(query)
1885
+ const includeSubagentsRef = React.useRef(includeSubagents)
1886
+ const showBlankRef = React.useRef(showBlank)
1887
+ // Latest-value refs: background tasks read these instead of stale closures.
1888
+ pagesRef.current = pages
1889
+ rowCacheRef.current = rowCache
1890
+ tabRef.current = tab
1891
+ sortRef.current = sort
1892
+ queryRef.current = query
1893
+ includeSubagentsRef.current = includeSubagents
1894
+ showBlankRef.current = showBlank
1895
+
1896
+ const scope = dataScopeKey({ tab, workspaceFilter, query, sort, includeSubagents, showBlank })
1897
+ const isSearch = query !== ''
1898
+
1899
+ /** The two empty-session shortcuts' id lists; absent until a response lands. */
1900
+ const loadBlanks = async () => {
1901
+ try {
1902
+ const value = await getBlanks(includeSubagentsRef.current)
1903
+ if (value !== null && value !== undefined) setBlanks(value)
1904
+ } catch {
1905
+ // The shortcuts degrade to hidden buttons when /blanks is unavailable.
1906
+ setBlanks(null)
1907
+ }
1908
+ }
1462
1909
 
1463
- const load = React.useCallback(async (options = {}) => {
1464
- abortRef.current?.abort()
1910
+ /** Total / per-workspace bytes, fetched out of band and never awaited. */
1911
+ const loadSizes = async () => {
1912
+ sizesAbort.current?.abort()
1465
1913
  const controller = new AbortController()
1466
- abortRef.current = controller
1467
- if (options.silent !== true) setPhase((previous) => (previous === 'ready' ? previous : 'loading'))
1914
+ sizesAbort.current = controller
1915
+ setSizesPhase('loading')
1468
1916
  try {
1469
- const value = await getState(includeSubagents, controller.signal)
1917
+ const value = await getSizes('all', includeSubagentsRef.current, controller.signal)
1470
1918
  if (controller.signal.aborted) return
1471
- setState(value)
1472
- setPhase('ready')
1473
- setError(null)
1919
+ setSizes(normalizeSizes(value))
1920
+ setSizesPhase('ready')
1474
1921
  } catch (failure) {
1475
1922
  if (failure?.name === 'AbortError') return
1476
- setError(failure)
1477
- setPhase('error')
1923
+ setSizes(null)
1924
+ setSizesPhase('error')
1478
1925
  }
1479
- }, [includeSubagents])
1926
+ }
1927
+
1928
+ /**
1929
+ * First paint: `/overview` (counts only). A Host that predates the route
1930
+ * is detected once and served by the 0.1.x `/state` path from then on.
1931
+ */
1932
+ const loadOverview = async (options = {}) => {
1933
+ overviewAbort.current?.abort()
1934
+ const controller = new AbortController()
1935
+ overviewAbort.current = controller
1936
+ const silent = options.silent === true
1937
+ if (!silent && overviewRef.current === null) setOverviewPhase('loading')
1938
+ try {
1939
+ if (transportRef.current !== 'legacy') {
1940
+ try {
1941
+ const value = await getOverview(includeSubagentsRef.current, controller.signal)
1942
+ if (controller.signal.aborted) return
1943
+ transportRef.current = 'paged'
1944
+ setTransport('paged')
1945
+ overviewRef.current = value
1946
+ setOverview(value)
1947
+ setOverviewPhase('ready')
1948
+ setOverviewError(null)
1949
+ void loadBlanks()
1950
+ return
1951
+ } catch (failure) {
1952
+ if (failure?.name === 'AbortError') return
1953
+ if (transportRef.current !== 'loading' || !isMissingRoute(failure)) throw failure
1954
+ // Old Host: no /overview route at all. Fall back for good.
1955
+ transportRef.current = 'legacy'
1956
+ setTransport('legacy')
1957
+ }
1958
+ }
1959
+ const value = await getState(includeSubagentsRef.current, controller.signal)
1960
+ if (controller.signal.aborted) return
1961
+ legacyStateRef.current = value
1962
+ setLegacyState(value)
1963
+ setOverview(overviewFromState(value))
1964
+ setBlanks(blanksFromState(value))
1965
+ setOverviewPhase('ready')
1966
+ setOverviewError(null)
1967
+ } catch (failure) {
1968
+ if (failure?.name === 'AbortError') return
1969
+ setOverviewError(failure)
1970
+ setOverviewPhase('error')
1971
+ }
1972
+ }
1973
+
1974
+ /** Cross-workspace search: one request answers every matching workspace. */
1975
+ const runSearch = async () => {
1976
+ searchAbort.current?.abort()
1977
+ const controller = new AbortController()
1978
+ searchAbort.current = controller
1979
+ const epoch = pagesEpoch.current
1980
+ setSearchPhase('loading')
1981
+ setSearchError(null)
1982
+ try {
1983
+ const value = await getSearch({
1984
+ query: queryRef.current,
1985
+ tab: tabRef.current,
1986
+ sort: sortRef.current,
1987
+ subagents: includeSubagentsRef.current ? 1 : 0,
1988
+ blank: showBlankRef.current ? 1 : 0,
1989
+ limitPerGroup: GROUP_PAGE_SIZE,
1990
+ }, controller.signal)
1991
+ if (controller.signal.aborted || epoch !== pagesEpoch.current) return
1992
+ const groups = Array.isArray(value?.groups) ? value.groups : []
1993
+ const nextPages = new Map()
1994
+ const cacheRows = []
1995
+ for (const group of groups) {
1996
+ const id = group?.workspaceId === null || group?.workspaceId === undefined ? 'ungrouped' : group.workspaceId
1997
+ const rows = Array.isArray(group?.rows) ? group.rows : []
1998
+ nextPages.set(id, {
1999
+ rows,
2000
+ total: Number.isFinite(group?.total) ? group.total : rows.length,
2001
+ offset: rows.length,
2002
+ hasMore: group?.hasMore === true,
2003
+ phase: 'ready',
2004
+ error: null,
2005
+ })
2006
+ for (const row of rows) cacheRows.push(row)
2007
+ }
2008
+ pagesRef.current = nextPages
2009
+ setPages(nextPages)
2010
+ setRowCache((previous) => mergeRowCache(previous, cacheRows))
2011
+ setSearchPhase('ready')
2012
+ } catch (failure) {
2013
+ if (failure?.name === 'AbortError') return
2014
+ if (epoch !== pagesEpoch.current) return
2015
+ setSearchError(failure)
2016
+ setSearchPhase('error')
2017
+ }
2018
+ }
2019
+
2020
+ /** Load (or append) one workspace page; stale epochs are dropped. */
2021
+ const loadGroupPage = async (groupId, requestOffset, options = {}) => {
2022
+ if (transportRef.current !== 'paged') return
2023
+ const append = options.append === true
2024
+ const epoch = pagesEpoch.current
2025
+ const controller = new AbortController()
2026
+ pageControllers.current.get(groupId)?.abort()
2027
+ pageControllers.current.set(groupId, controller)
2028
+ setPages((previous) => {
2029
+ const next = new Map(previous)
2030
+ const page = next.get(groupId) ?? { rows: [], total: 0, offset: 0, hasMore: false }
2031
+ next.set(groupId, { ...page, phase: 'loading', error: null })
2032
+ return next
2033
+ })
2034
+ try {
2035
+ const value = await getSessions({
2036
+ workspaceId: groupId,
2037
+ tab: tabRef.current,
2038
+ query: queryRef.current,
2039
+ sort: sortRef.current,
2040
+ subagents: includeSubagentsRef.current ? 1 : 0,
2041
+ blank: showBlankRef.current ? 1 : 0,
2042
+ offset: requestOffset,
2043
+ limit: GROUP_PAGE_SIZE,
2044
+ sizes: 1,
2045
+ }, controller.signal)
2046
+ if (controller.signal.aborted || epoch !== pagesEpoch.current) return
2047
+ const incoming = Array.isArray(value?.rows) ? value.rows : []
2048
+ setPages((previous) => {
2049
+ const next = new Map(previous)
2050
+ const page = next.get(groupId) ?? { rows: [], total: 0, offset: 0, hasMore: false }
2051
+ const rows = append ? mergePageRows(page.rows, incoming) : incoming
2052
+ next.set(groupId, {
2053
+ rows,
2054
+ total: Number.isFinite(value?.total) ? value.total : rows.length,
2055
+ offset: (Number.isFinite(requestOffset) ? requestOffset : 0) + incoming.length,
2056
+ hasMore: value?.hasMore === true,
2057
+ phase: 'ready',
2058
+ error: null,
2059
+ })
2060
+ return next
2061
+ })
2062
+ setRowCache((previous) => mergeRowCache(previous, incoming))
2063
+ // Walk back down to the depth this group had loaded before a refresh
2064
+ // (writes and the Refresh button preserve selection/scroll).
2065
+ const target = preserveCounts.current.get(groupId)
2066
+ if (target !== undefined) {
2067
+ const loaded = (Number.isFinite(requestOffset) ? requestOffset : 0) + incoming.length
2068
+ if (loaded < target && value?.hasMore === true) {
2069
+ if (!preserveChain.current.has(groupId)) {
2070
+ preserveChain.current.add(groupId)
2071
+ schedulePage(async () => {
2072
+ try {
2073
+ await loadGroupPage(groupId, loaded, { append: true })
2074
+ } finally {
2075
+ preserveChain.current.delete(groupId)
2076
+ }
2077
+ })
2078
+ }
2079
+ } else {
2080
+ preserveCounts.current.delete(groupId)
2081
+ }
2082
+ }
2083
+ } catch (failure) {
2084
+ if (failure?.name === 'AbortError') return
2085
+ if (epoch !== pagesEpoch.current) return
2086
+ setPages((previous) => {
2087
+ const next = new Map(previous)
2088
+ const page = next.get(groupId) ?? { rows: [], total: 0, offset: 0, hasMore: false }
2089
+ next.set(groupId, { ...page, phase: 'error', error: failure })
2090
+ return next
2091
+ })
2092
+ } finally {
2093
+ if (pageControllers.current.get(groupId) === controller) pageControllers.current.delete(groupId)
2094
+ }
2095
+ }
2096
+
2097
+ /** Queue one page load under the shared concurrency ceiling. */
2098
+ const schedulePage = (task) => {
2099
+ const state = limiter.current
2100
+ state.queue.push(task)
2101
+ pumpLimiter(state)
2102
+ }
2103
+
2104
+ const ensureGroupPage = (groupId, force = false) => {
2105
+ if (transportRef.current !== 'paged') return
2106
+ if (force !== true && pagesRef.current.has(groupId)) return
2107
+ if (inflightPages.current.has(groupId)) return
2108
+ inflightPages.current.add(groupId)
2109
+ schedulePage(async () => {
2110
+ try {
2111
+ await loadGroupPage(groupId, 0)
2112
+ } finally {
2113
+ inflightPages.current.delete(groupId)
2114
+ }
2115
+ })
2116
+ }
2117
+
2118
+ const loadMoreGroup = (groupId) => {
2119
+ if (transportRef.current !== 'paged') return
2120
+ if (inflightPages.current.has(groupId)) return
2121
+ const page = pagesRef.current.get(groupId)
2122
+ const offset = page?.rows?.length ?? 0
2123
+ inflightPages.current.add(groupId)
2124
+ schedulePage(async () => {
2125
+ try {
2126
+ await loadGroupPage(groupId, offset, { append: true })
2127
+ } finally {
2128
+ inflightPages.current.delete(groupId)
2129
+ }
2130
+ })
2131
+ }
2132
+
2133
+ const retryGroup = (groupId) => {
2134
+ ensureGroupPage(groupId, true)
2135
+ }
2136
+
2137
+ /** Drop every loaded page (scope change or an explicit refresh). */
2138
+ const resetPageCache = (clearSelection) => {
2139
+ pagesEpoch.current += 1
2140
+ for (const controller of pageControllers.current.values()) controller.abort()
2141
+ pageControllers.current.clear()
2142
+ inflightPages.current.clear()
2143
+ preserveChain.current = new Set()
2144
+ if (clearSelection === true) preserveCounts.current = new Map()
2145
+ setLegacyLimits({})
2146
+ pagesRef.current = new Map()
2147
+ setPages(new Map())
2148
+ if (clearSelection === true) setSelected(new Set())
2149
+ setSearchPhase('idle')
2150
+ setSearchError(null)
2151
+ }
2152
+
2153
+ /** Refresh after a write or the Refresh button: overview first, then pages. */
2154
+ const refresh = async () => {
2155
+ // Remember how deep each loaded group was so the refetch keeps the
2156
+ // user's scroll position instead of collapsing back to one page.
2157
+ const counts = new Map()
2158
+ for (const [groupId, page] of pagesRef.current) {
2159
+ if (page?.phase === 'ready' && (page.rows?.length ?? 0) > 0) counts.set(groupId, page.rows.length)
2160
+ }
2161
+ preserveCounts.current = counts
2162
+ await loadOverview({ silent: true })
2163
+ setReload((value) => value + 1)
2164
+ setDetailNonce((value) => value + 1)
2165
+ }
2166
+
2167
+ /**
2168
+ * Reload the whole DSH page. The sidebar keeps the workspace grouping it
2169
+ * loaded, so a moved session stays under the old workspace until the page
2170
+ * reloads; this is the same call the plugin market makes after a change.
2171
+ */
2172
+ const reloadPage = () => {
2173
+ try {
2174
+ window.location.reload()
2175
+ } catch {
2176
+ // A host without a reloadable location leaves the page as it is.
2177
+ }
2178
+ }
1480
2179
 
1481
2180
  React.useEffect(() => {
1482
- void load()
1483
- return () => abortRef.current?.abort()
1484
- }, [load])
2181
+ void loadOverview()
2182
+ return () => {
2183
+ overviewAbort.current?.abort()
2184
+ for (const controller of pageControllers.current.values()) controller.abort()
2185
+ }
2186
+ }, [includeSubagents])
1485
2187
 
2188
+ // The window gaining focus only refreshes the cheap first paint; open
2189
+ // workspaces stay as they are and reload on the next explicit action.
1486
2190
  React.useEffect(() => {
1487
- const onFocus = () => void load({ silent: true })
2191
+ const onFocus = () => void loadOverview({ silent: true })
1488
2192
  window.addEventListener('focus', onFocus)
1489
2193
  return () => window.removeEventListener('focus', onFocus)
1490
- }, [load])
2194
+ }, [])
1491
2195
 
2196
+ // A scope change invalidates every page AND the selection (data safety:
2197
+ // an id selected under another tab/query must never reach a batch call).
1492
2198
  React.useEffect(() => {
1493
- if (selectedId === null) return
1494
- if (state !== null && !state.sessions.some((row) => row.id === selectedId)) setSelectedId(null)
1495
- }, [state, selectedId])
2199
+ resetPageCache(true)
2200
+ }, [scope])
2201
+
2202
+ // A write or manual refresh drops the loaded pages but keeps the selection.
2203
+ // The legacy path rebuilds its pages from /state instead of clearing them.
2204
+ React.useEffect(() => {
2205
+ if (reload === 0) return
2206
+ if (transportRef.current === 'legacy') {
2207
+ setLegacyLimits({})
2208
+ return
2209
+ }
2210
+ resetPageCache(false)
2211
+ }, [reload])
2212
+
2213
+ // Old-Host fallback: pages are carved locally out of one /state payload.
2214
+ React.useEffect(() => {
2215
+ if (transport !== 'legacy' || legacyState === null) return
2216
+ const viewNow = computeView({
2217
+ state: legacyState,
2218
+ tab,
2219
+ workspaceFilter,
2220
+ query,
2221
+ sort,
2222
+ expanded: null,
2223
+ defaultExpanded: false,
2224
+ limits: {},
2225
+ showBlank,
2226
+ })
2227
+ const nextPages = new Map()
2228
+ for (const group of viewNow.groups) {
2229
+ nextPages.set(group.id, {
2230
+ rows: group.rows,
2231
+ total: group.rows.length,
2232
+ offset: group.rows.length,
2233
+ hasMore: false,
2234
+ phase: 'ready',
2235
+ error: null,
2236
+ })
2237
+ }
2238
+ pagesRef.current = nextPages
2239
+ setPages(nextPages)
2240
+ setRowCache((previous) => mergeRowCache(previous, viewNow.groups.flatMap((group) => group.rows)))
2241
+ setSizes({
2242
+ totalBytes: bytesOf(legacyState?.totals?.bytes),
2243
+ byWorkspace: new Map(viewNow.groups.map((group) => [group.id, group.bytes])),
2244
+ })
2245
+ setSizesPhase('ready')
2246
+ }, [transport, legacyState, scope, reload])
2247
+
2248
+ // Search is server-side and debounced through `query`; the applied query
2249
+ // is part of the scope key, so old results never survive a new search.
2250
+ React.useEffect(() => {
2251
+ if (transport !== 'paged' || query.trim() === '') return undefined
2252
+ void runSearch()
2253
+ return () => searchAbort.current?.abort()
2254
+ }, [transport, scope, reload])
2255
+
2256
+ React.useEffect(() => {
2257
+ if (transport !== 'paged') return
2258
+ void loadSizes()
2259
+ return () => sizesAbort.current?.abort()
2260
+ }, [transport, includeSubagents, reload])
2261
+
2262
+ // Typing is debounced; `query` only changes once the user pauses.
2263
+ React.useEffect(() => {
2264
+ const text = queryInput.trim()
2265
+ if (text === query) return undefined
2266
+ const timer = setTimeout(() => setQuery(text), SEARCH_DEBOUNCE_MS)
2267
+ return () => clearTimeout(timer)
2268
+ }, [queryInput, query])
1496
2269
 
1497
2270
  React.useEffect(() => {
1498
2271
  if (selectedId === null) return
1499
- const detail = document.querySelector('.dsm-detail')
1500
- const parent = scrollParentOf(detail)
2272
+ const detailElement = document.querySelector('.dsm-detail')
2273
+ const parent = scrollParentOf(detailElement)
1501
2274
  if (parent !== null && typeof parent.scrollTop === 'number') parent.scrollTop = 0
1502
2275
  }, [selectedId])
1503
2276
 
1504
- const openDetail = (id) => {
1505
- const page = document.querySelector('.dsm-page')
1506
- const parent = scrollParentOf(page)
1507
- listScroll.current = parent?.scrollTop ?? 0
1508
- setSelectedId(id)
1509
- }
1510
-
1511
- const backToList = () => {
1512
- setSelectedId(null)
1513
- requestAnimationFrame(() => {
1514
- const page = document.querySelector('.dsm-page')
1515
- const parent = scrollParentOf(page)
1516
- if (parent !== null && typeof parent.scrollTop === 'number') parent.scrollTop = listScroll.current
1517
- })
1518
- }
1519
-
1520
2277
  React.useEffect(() => {
1521
2278
  if (selectedId === null) return undefined
1522
2279
  const onKeyDown = (event) => {
@@ -1530,45 +2287,173 @@ window.__ModuleLoader__.load({
1530
2287
  return () => document.removeEventListener('keydown', onKeyDown, true)
1531
2288
  }, [selectedId])
1532
2289
 
2290
+ // Detail row: render the cached row immediately, then refresh it from the
2291
+ // dedicated route; only an explicit not-found clears the view.
1533
2292
  React.useEffect(() => {
1534
- setLimits({})
1535
- }, [tab, workspaceFilter, query, includeSubagents, showBlank])
2293
+ detailAbort.current?.abort()
2294
+ if (selectedId === null) {
2295
+ setDetail(null)
2296
+ setDetailPhase('idle')
2297
+ setDetailError(null)
2298
+ return undefined
2299
+ }
2300
+ const cached = rowCacheRef.current.get(selectedId)
2301
+ if (cached !== undefined) {
2302
+ setDetail(cached)
2303
+ setDetailPhase('ready')
2304
+ } else {
2305
+ setDetail(null)
2306
+ setDetailPhase('loading')
2307
+ }
2308
+ const controller = new AbortController()
2309
+ detailAbort.current = controller
2310
+ if (transportRef.current === 'legacy') {
2311
+ const stateNow = legacyStateRef.current
2312
+ const row = (stateNow?.sessions ?? []).find((item) => item.id === selectedId)
2313
+ if (row !== undefined) {
2314
+ setDetail(row)
2315
+ setDetailPhase('ready')
2316
+ } else if (cached === undefined) {
2317
+ setDetailPhase('missing')
2318
+ }
2319
+ return () => controller.abort()
2320
+ }
2321
+ getSession(selectedId, controller.signal)
2322
+ .then((value) => {
2323
+ if (controller.signal.aborted) return
2324
+ if (value?.row !== undefined && value?.row !== null) {
2325
+ setDetail(value.row)
2326
+ setDetailPhase('ready')
2327
+ setDetailError(null)
2328
+ setRowCache((previous) => mergeRowCache(previous, [value.row]))
2329
+ } else if (cached === undefined) {
2330
+ setDetailPhase('missing')
2331
+ }
2332
+ })
2333
+ .catch((failure) => {
2334
+ if (failure?.name === 'AbortError') return
2335
+ // The Host answers an unknown id with `not-found`; that is the only
2336
+ // case that may clear the detail view. A missing *route* (old Host)
2337
+ // keeps the cached row visible instead.
2338
+ if (failure?.code === 'not-found') {
2339
+ setDetail(null)
2340
+ setDetailPhase('missing')
2341
+ return
2342
+ }
2343
+ if (isMissingRoute(failure)) {
2344
+ if (cached === undefined) setDetailPhase('missing')
2345
+ return
2346
+ }
2347
+ if (cached === undefined) {
2348
+ setDetailError(failure)
2349
+ setDetailPhase('error')
2350
+ }
2351
+ })
2352
+ return () => controller.abort()
2353
+ }, [selectedId, detailNonce])
1536
2354
 
1537
- const rows = state?.sessions ?? []
1538
- const defaultExpanded = (state?.totals?.all ?? 0) <= DEFAULT_COLLAPSE_AFTER
2355
+ const totals = overview?.totals ?? {}
2356
+ const limits = overview?.limits ?? {}
2357
+ const defaultExpanded = (totals.all ?? 0) <= DEFAULT_COLLAPSE_AFTER
1539
2358
  const view = React.useMemo(
1540
- () => computeView({ state, tab, workspaceFilter, query, sort, expanded, defaultExpanded, limits, showBlank }),
1541
- [state, tab, workspaceFilter, query, sort, expanded, defaultExpanded, limits, showBlank],
1542
- )
1543
- const viewKey = React.useMemo(
1544
- () => [
1545
- state?.generatedAt ?? 0,
2359
+ () => composeGroups({
2360
+ overview,
2361
+ pages,
2362
+ sizes,
2363
+ expanded,
2364
+ defaultExpanded,
2365
+ query,
1546
2366
  tab,
1547
2367
  workspaceFilter,
1548
- query,
1549
- includeSubagents ? 1 : 0,
1550
- showBlank ? 1 : 0,
1551
- defaultExpanded ? 1 : 0,
1552
- expanded === null ? 'auto' : [...expanded].sort().join(','),
1553
- ].join('|'),
1554
- [state?.generatedAt, tab, workspaceFilter, query, includeSubagents, showBlank, defaultExpanded, expanded],
2368
+ showBlank,
2369
+ searchMode: transport === 'paged' && isSearch,
2370
+ requirePage: transport === 'legacy',
2371
+ legacy: transport === 'legacy',
2372
+ legacyLimits,
2373
+ }),
2374
+ [overview, pages, sizes, expanded, defaultExpanded, query, tab, workspaceFilter, showBlank, transport, isSearch, legacyLimits],
2375
+ )
2376
+ const expandedIdsKey = view.groups.filter((group) => group.expanded === true).map((group) => group.id).join('|')
2377
+ React.useEffect(() => {
2378
+ if (transport !== 'paged' || isSearch) return
2379
+ for (const group of view.groups) if (group.expanded === true) ensureGroupPage(group.id)
2380
+ }, [transport, scope, reload, expandedIdsKey])
2381
+
2382
+ const visibleKey = React.useMemo(
2383
+ () => view.groups.filter((group) => group.expanded === true).map((group) => group.rows.map((row) => row.id).join(',')).join('|'),
2384
+ [view],
1555
2385
  )
1556
2386
  React.useEffect(() => {
1557
2387
  setSelected((previous) => {
1558
2388
  if (previous.size === 0) return previous
2389
+ const visible = new Set()
2390
+ for (const group of view.groups) {
2391
+ if (group.expanded !== true) continue
2392
+ for (const row of group.rows) visible.add(row.id)
2393
+ }
1559
2394
  const next = new Set()
1560
- for (const id of previous) if (view.visibleIdSet.has(id)) next.add(id)
2395
+ for (const id of previous) if (visible.has(id)) next.add(id)
1561
2396
  return next.size === previous.size ? previous : next
1562
2397
  })
1563
- }, [viewKey])
2398
+ }, [visibleKey])
2399
+
2400
+ const rows = overview === null ? [] : view.groups
2401
+ const blankIds = blanks ?? { unarchivedIds: [], archivedIds: [], deletableIds: [] }
2402
+ const tabCounts = {
2403
+ all: Math.max(0, (totals.all ?? 0) - (showBlank ? 0 : (totals.blank ?? 0))),
2404
+ active: Math.max(0, (totals.active ?? 0) - (showBlank ? 0 : (blankIds.unarchivedIds.length ?? 0))),
2405
+ archived: Math.max(0, (totals.archived ?? 0) - (showBlank ? 0 : (blankIds.archivedIds.length ?? 0))),
2406
+ }
2407
+ const tabs = [
2408
+ { key: 'all', label: t('tabAll'), count: tabCounts.all },
2409
+ { key: 'active', label: t('tabActive'), count: tabCounts.active },
2410
+ { key: 'archived', label: t('tabArchived'), count: tabCounts.archived },
2411
+ ]
2412
+ const maxBatch = limits.maxBatch ?? 50
2413
+ const maxMoveBatch = limits.maxMoveBatch ?? 20
2414
+ const selectedRows = [...selected].map((id) => rowCache.get(id)).filter((row) => row !== undefined)
2415
+ const visibleAllSelected = view.visibleRows.length > 0 && view.visibleRows.every((row) => selected.has(row.id))
2416
+ const overBatch = selectedRows.length > maxBatch
2417
+ const overMove = selectedRows.length > maxMoveBatch
2418
+ const selectedArchived = selectedRows.length > 0 && !overBatch && selectedRows.every((row) => row.archived)
2419
+ const selectedActive = selectedRows.length > 0 && !overBatch && selectedRows.every((row) => !row.archived)
2420
+ const selectedMovable =
2421
+ selectedRows.length > 0 && !overMove &&
2422
+ selectedRows.every((row) => row.can?.move === true) &&
2423
+ new Set(selectedRows.map((row) => row.workspaceId)).size === 1
2424
+ const selectedDeletable = selectedRows.length > 0 && !overBatch && selectedRows.every((row) => row.can?.delete === true)
2425
+ const targetWorkspaces = (overview?.workspaces ?? []).filter((workspace) => {
2426
+ if (workspace.status !== 'ok') return false
2427
+ return !selectedRows.some((row) => row.workspaceId === workspace.id)
2428
+ })
2429
+ const totalBytes = sizes?.totalBytes
2430
+ const sizeText = Number.isFinite(totalBytes) ? formatBytes(totalBytes) : '—'
2431
+
2432
+ /** Compact per-session failures a batch reports inside a 200 response. */
2433
+ const failuresOf = (value) => {
2434
+ const failed = (value?.results ?? []).filter((item) => item.ok === false)
2435
+ if (failed.length === 0) return undefined
2436
+ return failed.slice(0, 20).map((item) => ({
2437
+ sessionId: item.sessionId,
2438
+ code: item.error?.code ?? 'failed',
2439
+ message: item.error?.message ?? '',
2440
+ }))
2441
+ }
1564
2442
 
1565
2443
  const run = async (operation, successText) => {
1566
2444
  setBusy(true)
1567
2445
  setNotice(null)
1568
2446
  try {
1569
2447
  const value = await operation()
1570
- setNotice({ tone: 'ok', text: successText(value) })
1571
- await load({ silent: true })
2448
+ // A batch can answer 200 while every item failed; surface the reasons
2449
+ // instead of reporting a bare "0 succeeded / 1 failed".
2450
+ const failures = failuresOf(value)
2451
+ setNotice(
2452
+ failures === undefined
2453
+ ? { tone: 'ok', text: successText(value) }
2454
+ : { tone: 'error', text: successText(value), details: failures },
2455
+ )
2456
+ await refresh()
1572
2457
  return value
1573
2458
  } catch (failure) {
1574
2459
  setNotice({ tone: 'error', text: errorText(t, failure), detail: failure?.message })
@@ -1579,9 +2464,15 @@ window.__ModuleLoader__.load({
1579
2464
  }
1580
2465
 
1581
2466
  const resultText = (value) => {
1582
- const totals = value?.totals
1583
- if (totals === undefined) return t('working')
1584
- return t('batchDone', { ok: totals.succeeded, failed: totals.failed })
2467
+ const valueTotals = value?.totals
2468
+ if (valueTotals === undefined) return t('working')
2469
+ const base = t('batchDone', { ok: valueTotals.succeeded, failed: valueTotals.failed })
2470
+ // Moves carry subagent descendants along; say so, because the count of
2471
+ // moved rows is larger than the number of selected sessions.
2472
+ const family = (value?.results ?? [])
2473
+ .filter((item) => item.ok === true && Number.isFinite(item.family) && item.family > 1)
2474
+ .reduce((sum, item) => sum + (item.family - 1), 0)
2475
+ return family > 0 ? `${base} · ${t('movedWithFamily', { n: family })}` : base
1585
2476
  }
1586
2477
 
1587
2478
  const archiveRows = async (ids, stopActivity = false) => {
@@ -1605,11 +2496,10 @@ window.__ModuleLoader__.load({
1605
2496
  }
1606
2497
 
1607
2498
  const moveRows = async (ids, workspaceId) => {
1608
- const value = await run(
2499
+ return await run(
1609
2500
  () => post('move', { sessionIds: ids, workspaceId }),
1610
2501
  (result) => resultText(result),
1611
2502
  )
1612
- return value
1613
2503
  }
1614
2504
 
1615
2505
  /**
@@ -1620,15 +2510,14 @@ window.__ModuleLoader__.load({
1620
2510
  */
1621
2511
  const deleteRows = async (ids) => {
1622
2512
  if (ids.length === 0) return undefined
1623
- const max = state?.limits?.maxBatch ?? 50
1624
2513
  setBusy(true)
1625
2514
  setNotice(null)
1626
2515
  try {
1627
2516
  let succeeded = 0
1628
2517
  let failed = 0
1629
2518
  const failures = []
1630
- for (let index = 0; index < ids.length; index += max) {
1631
- const value = await post('delete', { sessionIds: ids.slice(index, index + max), confirm: true })
2519
+ for (let index = 0; index < ids.length; index += maxBatch) {
2520
+ const value = await post('delete', { sessionIds: ids.slice(index, index + maxBatch), confirm: true })
1632
2521
  succeeded += value?.totals?.succeeded ?? 0
1633
2522
  failed += value?.totals?.failed ?? 0
1634
2523
  for (const item of value?.results ?? []) if (item.ok === false) failures.push(item)
@@ -1641,7 +2530,7 @@ window.__ModuleLoader__.load({
1641
2530
  setBulkDeleteIds(null)
1642
2531
  setSelected(new Set())
1643
2532
  if (selectedId !== null && ids.includes(selectedId)) setSelectedId(null)
1644
- await load({ silent: true })
2533
+ await refresh()
1645
2534
  return { totals: { succeeded, failed } }
1646
2535
  } catch (failure) {
1647
2536
  setNotice({ tone: 'error', text: errorText(t, failure), detail: failure?.message })
@@ -1652,19 +2541,18 @@ window.__ModuleLoader__.load({
1652
2541
  }
1653
2542
 
1654
2543
  const cleanupOrphans = async () => {
1655
- const ids = state?.orphans ?? []
2544
+ const ids = overview?.orphans ?? []
1656
2545
  if (ids.length === 0) return
1657
- const max = state?.limits?.maxBatch ?? 50
1658
2546
  setBusy(true)
1659
2547
  setNotice(null)
1660
2548
  try {
1661
2549
  let cleaned = 0
1662
- for (let index = 0; index < ids.length; index += max) {
1663
- await post('unarchive', { sessionIds: ids.slice(index, index + max) })
1664
- cleaned += Math.min(max, ids.length - index)
2550
+ for (let index = 0; index < ids.length; index += maxBatch) {
2551
+ await post('unarchive', { sessionIds: ids.slice(index, index + maxBatch) })
2552
+ cleaned += Math.min(maxBatch, ids.length - index)
1665
2553
  }
1666
2554
  setNotice({ tone: 'ok', text: t('cleanedOrphans', { n: cleaned }) })
1667
- await load({ silent: true })
2555
+ await refresh()
1668
2556
  } catch (failure) {
1669
2557
  setNotice({ tone: 'error', text: errorText(t, failure) })
1670
2558
  } finally {
@@ -1678,14 +2566,13 @@ window.__ModuleLoader__.load({
1678
2566
  */
1679
2567
  const archiveBlankSessions = async (ids) => {
1680
2568
  if (ids.length === 0) return
1681
- const max = state?.limits?.maxBatch ?? 50
1682
2569
  setBusy(true)
1683
2570
  setNotice(null)
1684
2571
  try {
1685
2572
  let archived = 0
1686
2573
  const failures = []
1687
- for (let index = 0; index < ids.length; index += max) {
1688
- const value = await post('archive', { sessionIds: ids.slice(index, index + max) })
2574
+ for (let index = 0; index < ids.length; index += maxBatch) {
2575
+ const value = await post('archive', { sessionIds: ids.slice(index, index + maxBatch) })
1689
2576
  archived += value?.totals?.succeeded ?? 0
1690
2577
  for (const item of value?.results ?? []) if (item.ok === false) failures.push(item)
1691
2578
  }
@@ -1698,7 +2585,7 @@ window.__ModuleLoader__.load({
1698
2585
  details: failures.slice(0, 20),
1699
2586
  })
1700
2587
  }
1701
- await load({ silent: true })
2588
+ await refresh()
1702
2589
  } catch (failure) {
1703
2590
  setNotice({ tone: 'error', text: errorText(t, failure) })
1704
2591
  } finally {
@@ -1727,44 +2614,21 @@ window.__ModuleLoader__.load({
1727
2614
  if (value !== undefined) openSession(id)
1728
2615
  }
1729
2616
 
1730
- if (state === null) {
1731
- if (phase === 'error') {
1732
- return h(
1733
- 'div',
1734
- { className: 'dsm-page' },
1735
- h('h2', { className: 'dsm-title' }, t('pageTitle')),
1736
- h('p', { className: 'dsm-error' }, t('loadFailed', { message: errorText(t, error) })),
1737
- h('button', { type: 'button', className: 'dsm-btn', onClick: () => void load() }, t('retry')),
1738
- )
1739
- }
1740
- return h('div', { className: 'dsm-page' }, h('p', { className: 'dsm-hint' }, h(Spinner, { t, label: t('loading') })))
2617
+ const openDetail = (id) => {
2618
+ const page = document.querySelector('.dsm-page')
2619
+ const parent = scrollParentOf(page)
2620
+ listScroll.current = parent?.scrollTop ?? 0
2621
+ setSelectedId(id)
1741
2622
  }
1742
2623
 
1743
- const rowsForTabs = rows.filter((row) => showBlank || row.blank !== true)
1744
- const blankRows = rows.filter((row) => row.blank === true)
1745
- const blankUnarchivedRows = blankRows.filter((row) => row.archived !== true)
1746
- const blankArchivedRows = blankRows.filter((row) => row.archived === true)
1747
- const blankDeletableRows = blankArchivedRows.filter((row) => row.can?.delete === true)
1748
- const tabs = [
1749
- { key: 'all', label: t('tabAll'), count: rowsForTabs.length },
1750
- { key: 'active', label: t('tabActive'), count: rowsForTabs.filter((row) => !row.archived).length },
1751
- { key: 'archived', label: t('tabArchived'), count: rowsForTabs.filter((row) => row.archived).length },
1752
- ]
1753
- const visibleAllSelected = view.visibleRows.length > 0 && view.visibleRows.every((row) => selected.has(row.id))
1754
- const selectedRows = view.visibleRows.filter((row) => selected.has(row.id))
1755
- const overBatch = selectedRows.length > (state.limits?.maxBatch ?? 50)
1756
- const overMove = selectedRows.length > (state.limits?.maxMoveBatch ?? 20)
1757
- const selectedArchived = selectedRows.length > 0 && !overBatch && selectedRows.every((row) => row.archived)
1758
- const selectedActive = selectedRows.length > 0 && !overBatch && selectedRows.every((row) => !row.archived)
1759
- const selectedMovable =
1760
- selectedRows.length > 0 && !overMove &&
1761
- selectedRows.every((row) => row.can?.move === true) &&
1762
- new Set(selectedRows.map((row) => row.workspaceId)).size === 1
1763
- const selectedDeletable = selectedRows.length > 0 && !overBatch && selectedRows.every((row) => row.can?.delete === true)
1764
- const targetWorkspaces = (state?.workspaces ?? []).filter((workspace) => {
1765
- if (workspace.status !== 'ok') return false
1766
- return !selectedRows.some((row) => row.workspaceId === workspace.id)
1767
- })
2624
+ const backToList = () => {
2625
+ setSelectedId(null)
2626
+ requestAnimationFrame(() => {
2627
+ const page = document.querySelector('.dsm-page')
2628
+ const parent = scrollParentOf(page)
2629
+ if (parent !== null && typeof parent.scrollTop === 'number') parent.scrollTop = listScroll.current
2630
+ })
2631
+ }
1768
2632
 
1769
2633
  const toggle = (id) => {
1770
2634
  setSelected((previous) => {
@@ -1803,16 +2667,44 @@ window.__ModuleLoader__.load({
1803
2667
  const expandAll = () => setExpanded(new Set(view.groups.map((group) => group.id)))
1804
2668
  const collapseAll = () => setExpanded(new Set())
1805
2669
  const loadMore = (id) => {
1806
- setLimits((previous) => ({ ...previous, [id]: (previous[id] ?? GROUP_PAGE_SIZE) + GROUP_PAGE_SIZE }))
2670
+ if (transportRef.current === 'legacy') {
2671
+ setLegacyLimits((previous) => ({ ...previous, [id]: (previous[id] ?? GROUP_PAGE_SIZE) + GROUP_PAGE_SIZE }))
2672
+ return
2673
+ }
2674
+ loadMoreGroup(id)
2675
+ }
2676
+
2677
+ if (overview === null) {
2678
+ if (overviewPhase === 'error') {
2679
+ return h(
2680
+ 'div',
2681
+ { className: 'dsm-page' },
2682
+ h('h2', { className: 'dsm-title' }, t('pageTitle')),
2683
+ h('p', { className: 'dsm-error' }, t('overviewLoadFailed', { message: errorText(t, overviewError) })),
2684
+ h('button', { type: 'button', className: 'dsm-btn', onClick: () => void loadOverview() }, t('retry')),
2685
+ )
2686
+ }
2687
+ return h('div', { className: 'dsm-page' }, h('p', { className: 'dsm-hint' }, h(Spinner, { t, label: t('loading') })))
1807
2688
  }
1808
- const isSearch = query.trim() !== ''
1809
2689
 
1810
2690
  if (selectedId !== null) {
1811
- const row = state.sessions.find((item) => item.id === selectedId)
2691
+ const row = detail ?? rowCache.get(selectedId)
1812
2692
  if (row === undefined) {
1813
- return h('div', { className: 'dsm-page' },
1814
- h('button', { type: 'button', className: 'dsm-btn ghost', onClick: backToList }, `← ${t('back')}`),
1815
- h('div', { className: 'dsm-empty' }, t('notFound')),
2693
+ const failed = detailPhase === 'error'
2694
+ const missing = detailPhase === 'missing'
2695
+ return h(
2696
+ 'div',
2697
+ { className: 'dsm-page' },
2698
+ h('div', { className: 'dsm-actions', style: { marginBottom: 4 } },
2699
+ h('button', { type: 'button', className: 'dsm-btn ghost', onClick: backToList }, `← ${t('back')}`),
2700
+ ),
2701
+ failed || missing
2702
+ ? h('p', { className: failed ? 'dsm-error' : 'dsm-empty' },
2703
+ failed ? t('loadFailed', { message: errorText(t, detailError) }) : t('notFound'))
2704
+ : h('p', { className: 'dsm-hint' }, h(Spinner, { t, label: t('loading') })),
2705
+ failed
2706
+ ? h('button', { type: 'button', className: 'dsm-btn', onClick: () => setDetailNonce((value) => value + 1) }, t('retry'))
2707
+ : null,
1816
2708
  )
1817
2709
  }
1818
2710
  return h(
@@ -1822,13 +2714,14 @@ window.__ModuleLoader__.load({
1822
2714
  h(DetailView, {
1823
2715
  ctx,
1824
2716
  t,
1825
- state,
2717
+ overview,
2718
+ limits,
1826
2719
  row,
1827
2720
  busy,
1828
2721
  onBack: backToList,
1829
2722
  onChanged: (text) => {
1830
2723
  setNotice({ tone: 'ok', text: text ?? t('copied') })
1831
- void load({ silent: true })
2724
+ void refresh()
1832
2725
  },
1833
2726
  onOpen: openSession,
1834
2727
  onArchive: (id) => void archiveRows([id]),
@@ -1850,51 +2743,68 @@ window.__ModuleLoader__.load({
1850
2743
  { className: 'dsm-head-row' },
1851
2744
  h('h2', { className: 'dsm-title' }, t('pageTitle')),
1852
2745
  h('div', { className: 'dsm-head-actions' },
1853
- blankUnarchivedRows.length > 0
2746
+ blankIds.unarchivedIds.length > 0
1854
2747
  ? h('button', {
1855
2748
  type: 'button',
1856
2749
  className: 'dsm-btn',
1857
2750
  disabled: busy,
1858
2751
  title: t('archiveBlankHint'),
1859
- onClick: () => void archiveBlankSessions(blankUnarchivedRows.map((row) => row.id)),
1860
- }, t('archiveBlank', { n: blankUnarchivedRows.length }))
2752
+ onClick: () => void archiveBlankSessions(blankIds.unarchivedIds),
2753
+ }, t('archiveBlank', { n: blankIds.unarchivedIds.length }))
1861
2754
  : null,
1862
- blankArchivedRows.length > 0
2755
+ blankIds.archivedIds.length > 0
1863
2756
  ? h('button', {
1864
2757
  type: 'button',
1865
2758
  className: 'dsm-btn',
1866
- disabled: busy || blankDeletableRows.length === 0,
1867
- title: blankDeletableRows.length === blankArchivedRows.length
2759
+ disabled: busy || blankIds.deletableIds.length === 0,
2760
+ title: blankIds.deletableIds.length === blankIds.archivedIds.length
1868
2761
  ? t('cleanupBlankHint')
1869
- : t('cleanupBlankLockedHint', { n: blankArchivedRows.length - blankDeletableRows.length }),
1870
- onClick: () => setBulkDeleteIds(blankDeletableRows.map((row) => row.id)),
1871
- }, t('cleanupBlank', { n: blankArchivedRows.length }))
2762
+ : t('cleanupBlankLockedHint', { n: blankIds.archivedIds.length - blankIds.deletableIds.length }),
2763
+ onClick: () => setBulkDeleteIds(blankIds.deletableIds),
2764
+ }, t('cleanupBlank', { n: blankIds.archivedIds.length }))
1872
2765
  : null,
1873
- h('button', { type: 'button', className: 'dsm-btn', disabled: busy, onClick: () => void load({ silent: true }) },
2766
+ h('button', { type: 'button', className: 'dsm-btn', disabled: busy, onClick: () => void refresh() },
1874
2767
  busy ? h(Spinner, { t, label: t('working') }) : t('refresh')),
2768
+ h('button', {
2769
+ type: 'button',
2770
+ className: 'dsm-btn',
2771
+ title: t('reloadPageHint'),
2772
+ onClick: reloadPage,
2773
+ }, t('reloadPage')),
1875
2774
  ),
1876
2775
  ),
1877
2776
  h('p', { className: 'dsm-sub' }, t('pageSubtitle')),
1878
2777
  h('p', { className: 'dsm-sub' },
1879
2778
  t('totalLine', {
1880
- all: state.totals.all,
1881
- archived: state.totals.archived,
1882
- size: formatBytes(state.totals.bytes),
2779
+ all: totals.all ?? 0,
2780
+ archived: totals.archived ?? 0,
2781
+ size: sizeText,
1883
2782
  }),
1884
- blankRows.length > 0 && !showBlank
1885
- ? ` · ${t('hiddenBlankCount', { n: blankRows.length })}`
2783
+ (totals.blank ?? 0) > 0 && !showBlank
2784
+ ? ` · ${t('hiddenBlankCount', { n: totals.blank })}`
1886
2785
  : '',
1887
- (state.totals?.hiddenSubagents ?? 0) > 0 && !includeSubagents
1888
- ? ` · ${t('hiddenSubagentCount', { n: state.totals.hiddenSubagents })}`
2786
+ (totals.hiddenSubagents ?? 0) > 0 && !includeSubagents
2787
+ ? ` · ${t('hiddenSubagentCount', { n: totals.hiddenSubagents })}`
1889
2788
  : '',
2789
+ sizesPhase === 'loading' ? ` · ${t('sizesPending')}` : '',
1890
2790
  ),
1891
2791
  ),
1892
2792
  h(Notice, { notice, t, onDismiss: () => setNotice(null) }),
1893
- state.orphans.length > 0
2793
+ overviewPhase === 'error'
2794
+ ? h(
2795
+ 'div',
2796
+ { className: 'dsm-note warn' },
2797
+ h('div', { style: { flex: '1 1 auto' } }, t('overviewLoadFailed', { message: errorText(t, overviewError) })),
2798
+ h('div', { className: 'dsm-note-actions' },
2799
+ h('button', { type: 'button', className: 'dsm-btn', disabled: busy, onClick: () => void refresh() }, t('retry')),
2800
+ ),
2801
+ )
2802
+ : null,
2803
+ (overview?.orphans?.length ?? 0) > 0
1894
2804
  ? h(
1895
2805
  'div',
1896
2806
  { className: 'dsm-note warn' },
1897
- h('div', { style: { flex: '1 1 auto' } }, t('orphanBanner', { n: state.orphans.length })),
2807
+ h('div', { style: { flex: '1 1 auto' } }, t('orphanBanner', { n: overview.orphans.length })),
1898
2808
  h('div', { className: 'dsm-note-actions' },
1899
2809
  h('button', { type: 'button', className: 'dsm-btn', disabled: busy, onClick: () => void cleanupOrphans() },
1900
2810
  busy ? t('cleaningOrphans') : t('cleanupOrphans')),
@@ -1902,139 +2812,142 @@ window.__ModuleLoader__.load({
1902
2812
  )
1903
2813
  : null,
1904
2814
  h('div', { className: 'dsm-sticky' },
1905
- h(
1906
- 'div',
1907
- { className: 'dsm-toolbar' },
1908
- h('div', { className: 'dsm-tabs', role: 'group' },
1909
- tabs.map((item) =>
1910
- h('button', {
1911
- key: item.key,
1912
- type: 'button',
1913
- className: 'dsm-tab',
1914
- 'aria-pressed': tab === item.key ? 'true' : 'false',
1915
- onClick: () => setTab(item.key),
1916
- }, `${item.label} ${item.count}`),
2815
+ h(
2816
+ 'div',
2817
+ { className: 'dsm-toolbar' },
2818
+ h('div', { className: 'dsm-tabs', role: 'group' },
2819
+ tabs.map((item) =>
2820
+ h('button', {
2821
+ key: item.key,
2822
+ type: 'button',
2823
+ className: 'dsm-tab',
2824
+ 'aria-pressed': tab === item.key ? 'true' : 'false',
2825
+ onClick: () => setTab(item.key),
2826
+ }, `${item.label} ${item.count}`),
2827
+ ),
1917
2828
  ),
1918
- ),
1919
- h('select', {
1920
- className: 'dsm-select',
1921
- value: workspaceFilter,
1922
- onChange: (event) => setWorkspaceFilter(event.target.value),
1923
- 'aria-label': t('workspaceAll'),
1924
- },
1925
- h('option', { value: 'all' }, t('workspaceAll')),
1926
- ...(state.workspaces ?? []).map((workspace) =>
1927
- h('option', { key: workspace.id, value: workspace.id },
1928
- `${workspace.title || workspace.path}${workspace.status === 'ok' ? '' : ` · ${t('workspaceMissing')}`}`),
2829
+ h('select', {
2830
+ className: 'dsm-select',
2831
+ value: workspaceFilter,
2832
+ onChange: (event) => setWorkspaceFilter(event.target.value),
2833
+ 'aria-label': t('workspaceAll'),
2834
+ },
2835
+ h('option', { value: 'all' }, t('workspaceAll')),
2836
+ ...(overview?.workspaces ?? []).map((workspace) =>
2837
+ h('option', { key: workspace.id, value: workspace.id },
2838
+ `${workspace.title || workspace.path}${workspace.status === 'ok' ? '' : ` · ${t('workspaceMissing')}`}`),
2839
+ ),
2840
+ h('option', { value: 'ungrouped' }, t('workspaceUngrouped')),
1929
2841
  ),
1930
- h('option', { value: 'ungrouped' }, t('workspaceUngrouped')),
1931
- ),
1932
- h('input', {
1933
- ref: searchRef,
1934
- className: 'dsm-input',
1935
- type: 'search',
1936
- value: query,
1937
- placeholder: t('searchPlaceholder'),
1938
- 'aria-label': t('searchPlaceholder'),
1939
- onChange: (event) => setQuery(event.target.value),
1940
- }),
1941
- h('select', {
1942
- className: 'dsm-select',
1943
- value: sort,
1944
- onChange: (event) => setSort(event.target.value),
1945
- 'aria-label': t('sortUpdated'),
1946
- },
1947
- h('option', { value: 'updated' }, t('sortUpdated')),
1948
- h('option', { value: 'created' }, t('sortCreated')),
1949
- h('option', { value: 'size' }, t('sortSize')),
1950
- h('option', { value: 'title' }, t('sortTitle')),
1951
- ),
1952
- h('label', { className: 'dsm-check', title: t('showSubagentsHint') },
1953
- h('input', {
1954
- type: 'checkbox',
1955
- checked: includeSubagents,
1956
- onChange: (event) => setIncludeSubagents(event.target.checked),
1957
- }),
1958
- t('showSubagents'),
1959
- ),
1960
- h('label', { className: 'dsm-check' },
1961
2842
  h('input', {
1962
- type: 'checkbox',
1963
- checked: showBlank,
1964
- onChange: (event) => setShowBlank(event.target.checked),
2843
+ ref: searchRef,
2844
+ className: 'dsm-input',
2845
+ type: 'search',
2846
+ value: queryInput,
2847
+ placeholder: t('searchPlaceholder'),
2848
+ 'aria-label': t('searchPlaceholder'),
2849
+ onChange: (event) => setQueryInput(event.target.value),
1965
2850
  }),
1966
- t('showBlank'),
2851
+ h('select', {
2852
+ className: 'dsm-select',
2853
+ value: sort,
2854
+ onChange: (event) => setSort(event.target.value),
2855
+ 'aria-label': t('sortUpdated'),
2856
+ },
2857
+ h('option', { value: 'updated' }, t('sortUpdated')),
2858
+ h('option', { value: 'created' }, t('sortCreated')),
2859
+ h('option', { value: 'size' }, t('sortSize')),
2860
+ h('option', { value: 'title' }, t('sortTitle')),
2861
+ ),
2862
+ h('label', { className: 'dsm-check', title: t('showSubagentsHint') },
2863
+ h('input', {
2864
+ type: 'checkbox',
2865
+ checked: includeSubagents,
2866
+ onChange: (event) => setIncludeSubagents(event.target.checked),
2867
+ }),
2868
+ t('showSubagents'),
2869
+ ),
2870
+ h('label', { className: 'dsm-check' },
2871
+ h('input', {
2872
+ type: 'checkbox',
2873
+ checked: showBlank,
2874
+ onChange: (event) => setShowBlank(event.target.checked),
2875
+ }),
2876
+ t('showBlank'),
2877
+ ),
1967
2878
  ),
1968
- ),
1969
- view.visibleRows.length > 0
1970
- ? h(
1971
- 'div',
1972
- { className: 'dsm-batch' },
1973
- h('label', { className: 'dsm-check' },
1974
- h('input', { type: 'checkbox', checked: visibleAllSelected, onChange: toggleAll }),
1975
- t('selectAll'),
1976
- ),
1977
- selectedRows.length > 0
1978
- ? h(React.Fragment, null,
1979
- h('span', { className: 'dsm-hint' }, t('selectedCount', { n: selectedRows.length })),
1980
- h('span', { className: 'dsm-hint' }, t('selectionScopeHint')),
1981
- overBatch ? h('span', { className: 'dsm-warn' }, t('tooMany', { max: state.limits?.maxBatch ?? 50 })) : null,
1982
- overMove ? h('span', { className: 'dsm-warn' }, t('tooMany', { max: state.limits?.maxMoveBatch ?? 20 })) : null,
1983
- h('button', {
1984
- type: 'button',
1985
- className: 'dsm-btn',
1986
- disabled: busy || !selectedActive,
1987
- title: selectedActive ? t('archiveHint') : t('noAction'),
1988
- onClick: () => void archiveRows(selectedRows.map((row) => row.id)),
1989
- }, t('archiveSelected')),
1990
- h('button', {
1991
- type: 'button',
1992
- className: 'dsm-btn',
1993
- disabled: busy || !selectedArchived,
1994
- onClick: () => void restoreRows(selectedRows.map((row) => row.id)),
1995
- }, t('restoreSelected')),
1996
- h('select', {
1997
- className: 'dsm-select',
1998
- value: bulkTarget,
1999
- disabled: busy || !selectedMovable,
2000
- onChange: (event) => setBulkTarget(event.target.value),
2001
- 'aria-label': t('moveTo'),
2002
- },
2003
- h('option', { value: '' }, t('moveTo')),
2004
- ...targetWorkspaces.map((workspace) =>
2005
- h('option', { key: workspace.id, value: workspace.id }, workspace.title || workspace.path),
2879
+ view.visibleRows.length > 0
2880
+ ? h(
2881
+ 'div',
2882
+ { className: 'dsm-batch' },
2883
+ h('label', { className: 'dsm-check' },
2884
+ h('input', { type: 'checkbox', checked: visibleAllSelected, onChange: toggleAll }),
2885
+ t('selectAll'),
2886
+ ),
2887
+ selectedRows.length > 0
2888
+ ? h(React.Fragment, null,
2889
+ h('span', { className: 'dsm-hint' }, t('selectedCount', { n: selectedRows.length })),
2890
+ h('span', { className: 'dsm-hint' }, t('selectionScopeHint')),
2891
+ view.partialCount > 0
2892
+ ? h('span', { className: 'dsm-hint' }, t('partialSelection', { n: view.partialCount }))
2893
+ : null,
2894
+ overBatch ? h('span', { className: 'dsm-warn' }, t('tooMany', { max: maxBatch })) : null,
2895
+ overMove ? h('span', { className: 'dsm-warn' }, t('tooMany', { max: maxMoveBatch })) : null,
2896
+ h('button', {
2897
+ type: 'button',
2898
+ className: 'dsm-btn',
2899
+ disabled: busy || !selectedActive,
2900
+ title: selectedActive ? t('archiveHint') : t('noAction'),
2901
+ onClick: () => void archiveRows(selectedRows.map((row) => row.id)),
2902
+ }, t('archiveSelected')),
2903
+ h('button', {
2904
+ type: 'button',
2905
+ className: 'dsm-btn',
2906
+ disabled: busy || !selectedArchived,
2907
+ onClick: () => void restoreRows(selectedRows.map((row) => row.id)),
2908
+ }, t('restoreSelected')),
2909
+ h('select', {
2910
+ className: 'dsm-select',
2911
+ value: bulkTarget,
2912
+ disabled: busy || !selectedMovable,
2913
+ onChange: (event) => setBulkTarget(event.target.value),
2914
+ 'aria-label': t('moveTo'),
2915
+ },
2916
+ h('option', { value: '' }, t('moveTo')),
2917
+ ...targetWorkspaces.map((workspace) =>
2918
+ h('option', { key: workspace.id, value: workspace.id }, workspace.title || workspace.path),
2919
+ ),
2006
2920
  ),
2007
- ),
2008
- h('button', {
2009
- type: 'button',
2010
- className: 'dsm-btn',
2011
- disabled: busy || !selectedMovable || bulkTarget === '',
2012
- onClick: () => void moveRows(selectedRows.map((row) => row.id), bulkTarget),
2013
- }, t('moveSelected')),
2014
- h('button', {
2015
- type: 'button',
2016
- className: 'dsm-btn danger',
2017
- disabled: busy || !selectedDeletable,
2018
- onClick: () => setBulkDeleteIds(selectedRows.map((row) => row.id)),
2019
- }, t('deleteSelected')),
2020
- h('button', {
2021
- type: 'button',
2022
- className: 'dsm-btn ghost',
2023
- disabled: busy,
2024
- onClick: () => setSelected(new Set()),
2025
- }, t('clearSelection')),
2026
- !selectedMovable && selectedRows.length > 0
2027
- ? h('span', { className: 'dsm-hint' },
2028
- selectedRows.some((row) => row.live || row.running)
2029
- ? t('liveHint')
2030
- : selectedRows.some((row) => row.kind === 'fork')
2031
- ? t('moveForkHint')
2032
- : t('moveUnavailableHint'))
2033
- : null,
2034
- )
2035
- : null,
2036
- )
2037
- : null,
2921
+ h('button', {
2922
+ type: 'button',
2923
+ className: 'dsm-btn',
2924
+ disabled: busy || !selectedMovable || bulkTarget === '',
2925
+ onClick: () => void moveRows(selectedRows.map((row) => row.id), bulkTarget),
2926
+ }, t('moveSelected')),
2927
+ h('button', {
2928
+ type: 'button',
2929
+ className: 'dsm-btn danger',
2930
+ disabled: busy || !selectedDeletable,
2931
+ onClick: () => setBulkDeleteIds(selectedRows.map((row) => row.id)),
2932
+ }, t('deleteSelected')),
2933
+ h('button', {
2934
+ type: 'button',
2935
+ className: 'dsm-btn ghost',
2936
+ disabled: busy,
2937
+ onClick: () => setSelected(new Set()),
2938
+ }, t('clearSelection')),
2939
+ !selectedMovable && selectedRows.length > 0
2940
+ ? h('span', { className: 'dsm-hint' },
2941
+ selectedRows.some((row) => row.live || row.running)
2942
+ ? t('liveHint')
2943
+ : selectedRows.some((row) => row.kind === 'fork')
2944
+ ? t('moveForkHint')
2945
+ : t('moveUnavailableHint'))
2946
+ : null,
2947
+ )
2948
+ : null,
2949
+ )
2950
+ : null,
2038
2951
  ),
2039
2952
  h('div', { className: 'dsm-list-actions' },
2040
2953
  h('button', {
@@ -2051,19 +2964,29 @@ window.__ModuleLoader__.load({
2051
2964
  }, t('collapseAll')),
2052
2965
  isSearch ? h('span', { className: 'dsm-hint' }, t('searchAutoExpand')) : null,
2053
2966
  ),
2967
+ isSearch && searchPhase === 'error'
2968
+ ? h(
2969
+ 'div',
2970
+ { className: 'dsm-note warn' },
2971
+ h('div', { style: { flex: '1 1 auto' } }, t('groupLoadFailed', { message: errorText(t, searchError) })),
2972
+ h('div', { className: 'dsm-note-actions' },
2973
+ h('button', { type: 'button', className: 'dsm-btn', disabled: busy, onClick: () => setReload((value) => value + 1) }, t('groupRetry')),
2974
+ ),
2975
+ )
2976
+ : null,
2054
2977
  bulkDeleteIds !== null
2055
2978
  ? h(DeleteScope, {
2056
2979
  ctx,
2057
2980
  t,
2058
2981
  ids: bulkDeleteIds,
2059
- maxBatch: state.limits?.maxBatch ?? 50,
2982
+ maxBatch,
2060
2983
  busy,
2061
2984
  onCancel: () => setBulkDeleteIds(null),
2062
2985
  onConfirmed: (ids) => void deleteRows(ids),
2063
2986
  })
2064
2987
  : null,
2065
- view.groups.length > 0
2066
- ? view.groups.map((group) =>
2988
+ rows.length > 0
2989
+ ? rows.map((group) =>
2067
2990
  h(Group, {
2068
2991
  key: group.id,
2069
2992
  group,
@@ -2072,11 +2995,14 @@ window.__ModuleLoader__.load({
2072
2995
  onSelect: toggle,
2073
2996
  onOpen: openDetail,
2074
2997
  onMore: () => loadMore(group.id),
2998
+ onRetry: () => retryGroup(group.id),
2075
2999
  locked: isSearch,
2076
3000
  t,
2077
3001
  }),
2078
3002
  )
2079
- : h('div', { className: 'dsm-empty' }, rows.length === 0 ? t('emptyNoSessions') : t('emptyFiltered')),
3003
+ : isSearch && searchPhase === 'loading'
3004
+ ? h('div', { className: 'dsm-empty' }, h(Spinner, { t, label: t('groupLoading') }))
3005
+ : h('div', { className: 'dsm-empty' }, (totals.all ?? 0) === 0 ? t('emptyNoSessions') : t('emptyFiltered')),
2080
3006
  h('p', { className: 'dsm-footer' }, t('archiveHint')),
2081
3007
  )
2082
3008
  }
@@ -2084,6 +3010,24 @@ window.__ModuleLoader__.load({
2084
3010
 
2085
3011
  return {
2086
3012
  inject: ['slots', 'locale'],
3013
+ /** Pure helpers for `node --test`; not part of the Host/client contract. */
3014
+ __test: {
3015
+ computeView,
3016
+ orderBranchRows,
3017
+ pruneSelection,
3018
+ scopeKey,
3019
+ dataScopeKey,
3020
+ queryOf,
3021
+ mergePageRows,
3022
+ mergeRowCache,
3023
+ annotateBranchRows,
3024
+ overviewFromState,
3025
+ blanksFromState,
3026
+ normalizeSizes,
3027
+ composeGroups,
3028
+ /** Components are exposed only so the vm test can smoke their render bodies. */
3029
+ components: { SessionManagerPage, Row, Group, DetailView },
3030
+ },
2087
3031
  apply(ctx) {
2088
3032
  const t = ctx.locale.bind(NS)
2089
3033
  ctx.effect(() => ctx.locale.register(NS, DICT), 'session-mng: dictionaries')