dsh-session-mng 0.1.4 → 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: {
@@ -58,6 +62,15 @@ window.__ModuleLoader__.load({
58
62
  loadMore: '加载更多(剩余 {n} 条)',
59
63
  selectionScopeHint: '选择仅作用于当前筛选与展开结果。',
60
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: '大小统计暂不可用,请稍后重试。',
61
74
  selectRow: '选择会话 {name}',
62
75
  justNow: '刚刚',
63
76
  titleApplied: '标题已写入。',
@@ -224,6 +237,15 @@ window.__ModuleLoader__.load({
224
237
  loadMore: 'Load {n} more',
225
238
  selectionScopeHint: 'Selection applies to the current filter and expanded workspaces.',
226
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.',
227
249
  selectRow: 'Select session {name}',
228
250
  justNow: 'just now',
229
251
  titleApplied: 'Title applied.',
@@ -430,6 +452,7 @@ window.__ModuleLoader__.load({
430
452
  .dsm-group-title{font-size:12px;font-weight:600;line-height:18px}
431
453
  .dsm-group-path{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);word-break:break-all}
432
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}
433
456
  .dsm-warn{color:var(--dsw-alias-state-warn-primary);font-size:11px}
434
457
  .dsm-row{display:grid;grid-template-columns:18px minmax(0,1fr) auto auto;align-items:center;gap:8px;
435
458
  padding:7px 10px;border-top:1px solid var(--dsw-alias-border-l1);min-width:0}
@@ -597,6 +620,7 @@ window.__ModuleLoader__.load({
597
620
  : t('noAdapterUnknown')
598
621
  }
599
622
  if (code === 'title-invalid') return t('titleInvalid')
623
+ if (code === 'sizes-pending') return t('sizesUnavailable')
600
624
  if (code === 'timeout') return t('timeout')
601
625
  if (code === 'llm-error') return t('llmError', { message: error?.message ?? '' })
602
626
  if (code === 'session-active') return t('sessionActive')
@@ -638,6 +662,26 @@ window.__ModuleLoader__.load({
638
662
  const getState = (includeSubagents, signal) =>
639
663
  request('GET', `/state?subagents=${includeSubagents ? '1' : '0'}`, undefined, signal)
640
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
+
641
685
  const post = (action, body, signal) => request('POST', `/${action}`, body, signal)
642
686
 
643
687
  function useLocaleTick(ctx) {
@@ -723,7 +767,21 @@ window.__ModuleLoader__.load({
723
767
  function Row({ row, selected, onToggle, onOpen, t }) {
724
768
  const depth = row.branchDepth ?? 0
725
769
  const branch = row.kind === 'fork'
770
+ const glyph = depth > 0 || row.branchOrphan === true
726
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
+ : ''
727
785
  const badges = []
728
786
  if (row.running) badges.push(h(Badge, { key: 'run', tone: 'run' }, t('running')))
729
787
  if (row.archived) badges.push(h(Badge, { key: 'arch' }, t('archived')))
@@ -755,17 +813,13 @@ window.__ModuleLoader__.load({
755
813
  h(
756
814
  'span',
757
815
  { className: 'dsm-row-title' },
758
- depth > 0 ? h('span', { className: 'dsm-branch-glyph', 'aria-hidden': 'true' }, '↳') : null,
816
+ glyph ? h('span', { className: 'dsm-branch-glyph', 'aria-hidden': 'true' }, '↳') : null,
759
817
  row.title === '' ? t('untitled') : row.title,
760
818
  ),
761
819
  h(
762
820
  'span',
763
821
  { className: 'dsm-row-meta' },
764
- branch && row.parentTitle !== ''
765
- ? `${t('fromParent', { title: row.parentTitle })} · `
766
- : branch && row.parentPresent === false
767
- ? `${t('parentMissing')} · `
768
- : '',
822
+ parentNote,
769
823
  row.cwd === '' ? t('noCwd') : row.cwd,
770
824
  ' · ',
771
825
  row.id,
@@ -781,8 +835,15 @@ window.__ModuleLoader__.load({
781
835
  )
782
836
  }
783
837
 
784
- function Group({ group, selected, onToggle, onSelect, onOpen, onMore, t, locked }) {
785
- 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
786
847
  return h(
787
848
  'section',
788
849
  { className: 'dsm-group' },
@@ -815,19 +876,30 @@ window.__ModuleLoader__.load({
815
876
  ? h('div', { className: 'dsm-warn' }, t('workspaceMissing'))
816
877
  : null,
817
878
  ),
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
- }),
879
+ h('div', { className: 'dsm-group-meta' }, meta),
829
880
  ),
830
- 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'
831
903
  ? h(
832
904
  'button',
833
905
  { type: 'button', className: 'dsm-btn ghost dsm-more', onClick: onMore },
@@ -1172,12 +1244,12 @@ window.__ModuleLoader__.load({
1172
1244
  // Detail view
1173
1245
  // ---------------------------------------------------------------------
1174
1246
 
1175
- 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 }) {
1176
1248
  const [copied, setCopied] = React.useState(false)
1177
1249
  const [showDelete, setShowDelete] = React.useState(false)
1178
1250
  const deleteIds = React.useMemo(() => [row.id], [row.id])
1179
1251
  const [target, setTarget] = React.useState('')
1180
- 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')
1181
1253
  const movable = row.can?.move === true && targets.length > 0
1182
1254
  const uiWorkspace = (() => {
1183
1255
  try {
@@ -1324,7 +1396,7 @@ window.__ModuleLoader__.load({
1324
1396
  ctx,
1325
1397
  t,
1326
1398
  ids: deleteIds,
1327
- maxBatch: state.limits?.maxBatch ?? 50,
1399
+ maxBatch: limits?.maxBatch ?? 50,
1328
1400
  busy,
1329
1401
  onCancel: () => setShowDelete(false),
1330
1402
  onConfirmed: (ids) => onDelete(ids),
@@ -1370,16 +1442,11 @@ window.__ModuleLoader__.load({
1370
1442
  return out.length === rows.length ? out : rows
1371
1443
  }
1372
1444
 
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()
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()
1381
1448
  const filtering = text !== ''
1382
- const filtered = rows.filter((row) => {
1449
+ return rows.filter((row) => {
1383
1450
  if (tab === 'active' && row.archived) return false
1384
1451
  if (tab === 'archived' && !row.archived) return false
1385
1452
  if (workspaceFilter === 'ungrouped' && row.workspaceId !== null) return false
@@ -1391,20 +1458,37 @@ window.__ModuleLoader__.load({
1391
1458
  }
1392
1459
  return true
1393
1460
  })
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
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
1399
1479
  })
1480
+ }
1481
+
1482
+ /** Group sorted rows in workspace order, keeping the ungrouped group last. */
1483
+ function groupRows(sorted, workspaceOrder) {
1400
1484
  const baseGroups = []
1401
1485
  for (const workspace of workspaceOrder) {
1402
- const groupRows = orderBranchRows(sorted.filter((row) => row.workspaceId === workspace.id))
1403
- if (groupRows.length === 0) continue
1486
+ const rows = orderBranchRows(sorted.filter((row) => row.workspaceId === workspace.id))
1487
+ if (rows.length === 0) continue
1404
1488
  baseGroups.push({
1405
1489
  ...workspace,
1406
- rows: groupRows,
1407
- bytes: groupRows.reduce((sum, row) => sum + row.sizeBytes, 0),
1490
+ rows,
1491
+ bytes: rows.reduce((sum, row) => sum + row.sizeBytes, 0),
1408
1492
  })
1409
1493
  }
1410
1494
  const ungroupedRows = orderBranchRows(sorted.filter((row) => row.workspaceId === null))
@@ -1417,7 +1501,12 @@ window.__ModuleLoader__.load({
1417
1501
  bytes: ungroupedRows.reduce((sum, row) => sum + row.sizeBytes, 0),
1418
1502
  })
1419
1503
  }
1420
- 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) => {
1421
1510
  const isExpanded = filtering ? true : (expanded === null ? defaultExpanded : expanded.has(group.id))
1422
1511
  const limit = limits[group.id] ?? GROUP_PAGE_SIZE
1423
1512
  return {
@@ -1427,6 +1516,47 @@ window.__ModuleLoader__.load({
1427
1516
  renderedRows: isExpanded ? group.rows.slice(0, limit) : [],
1428
1517
  }
1429
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 })
1430
1560
  const visibleRows = groups.flatMap((group) => (group.expanded ? group.rows : []))
1431
1561
  return {
1432
1562
  filteredRows: sorted,
@@ -1436,87 +1566,691 @@ window.__ModuleLoader__.load({
1436
1566
  }
1437
1567
  }
1438
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
+
1439
1816
  function SessionManagerPage(props) {
1440
1817
  const { ctx, t } = props
1441
1818
  useLocaleTick(ctx)
1442
- const [state, setState] = React.useState(null)
1443
- const [phase, setPhase] = React.useState('loading')
1444
- const [error, setError] = React.useState(null)
1445
1819
  const [tab, setTab] = React.useState('all')
1446
1820
  const [workspaceFilter, setWorkspaceFilter] = React.useState('all')
1821
+ const [queryInput, setQueryInput] = React.useState('')
1447
1822
  const [query, setQuery] = React.useState('')
1448
1823
  const [sort, setSort] = React.useState('updated')
1449
1824
  const [includeSubagents, setIncludeSubagents] = React.useState(false)
1825
+ const [showBlank, setShowBlank] = React.useState(false)
1826
+ const [expanded, setExpanded] = React.useState(null)
1450
1827
  const [selected, setSelected] = React.useState(() => new Set())
1451
1828
  const [selectedId, setSelectedId] = React.useState(null)
1452
1829
  const [busy, setBusy] = React.useState(false)
1453
1830
  const [notice, setNotice] = React.useState(null)
1454
1831
  const [bulkDeleteIds, setBulkDeleteIds] = React.useState(null)
1455
1832
  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)
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)
1460
1855
  const searchRef = React.useRef(null)
1461
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
+ }
1462
1899
 
1463
- const load = React.useCallback(async (options = {}) => {
1464
- abortRef.current?.abort()
1900
+ /** Total / per-workspace bytes, fetched out of band and never awaited. */
1901
+ const loadSizes = async () => {
1902
+ sizesAbort.current?.abort()
1465
1903
  const controller = new AbortController()
1466
- abortRef.current = controller
1467
- if (options.silent !== true) setPhase((previous) => (previous === 'ready' ? previous : 'loading'))
1904
+ sizesAbort.current = controller
1905
+ setSizesPhase('loading')
1468
1906
  try {
1469
- const value = await getState(includeSubagents, controller.signal)
1907
+ const value = await getSizes('all', includeSubagentsRef.current, controller.signal)
1470
1908
  if (controller.signal.aborted) return
1471
- setState(value)
1472
- setPhase('ready')
1473
- setError(null)
1909
+ setSizes(normalizeSizes(value))
1910
+ setSizesPhase('ready')
1474
1911
  } catch (failure) {
1475
1912
  if (failure?.name === 'AbortError') return
1476
- setError(failure)
1477
- setPhase('error')
1913
+ setSizes(null)
1914
+ setSizesPhase('error')
1478
1915
  }
1479
- }, [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
+ }
1480
2156
 
1481
2157
  React.useEffect(() => {
1482
- void load()
1483
- return () => abortRef.current?.abort()
1484
- }, [load])
2158
+ void loadOverview()
2159
+ return () => {
2160
+ overviewAbort.current?.abort()
2161
+ for (const controller of pageControllers.current.values()) controller.abort()
2162
+ }
2163
+ }, [includeSubagents])
1485
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.
1486
2167
  React.useEffect(() => {
1487
- const onFocus = () => void load({ silent: true })
2168
+ const onFocus = () => void loadOverview({ silent: true })
1488
2169
  window.addEventListener('focus', onFocus)
1489
2170
  return () => window.removeEventListener('focus', onFocus)
1490
- }, [load])
2171
+ }, [])
1491
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).
1492
2175
  React.useEffect(() => {
1493
- if (selectedId === null) return
1494
- if (state !== null && !state.sessions.some((row) => row.id === selectedId)) setSelectedId(null)
1495
- }, [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])
1496
2246
 
1497
2247
  React.useEffect(() => {
1498
2248
  if (selectedId === null) return
1499
- const detail = document.querySelector('.dsm-detail')
1500
- const parent = scrollParentOf(detail)
2249
+ const detailElement = document.querySelector('.dsm-detail')
2250
+ const parent = scrollParentOf(detailElement)
1501
2251
  if (parent !== null && typeof parent.scrollTop === 'number') parent.scrollTop = 0
1502
2252
  }, [selectedId])
1503
2253
 
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
2254
  React.useEffect(() => {
1521
2255
  if (selectedId === null) return undefined
1522
2256
  const onKeyDown = (event) => {
@@ -1530,37 +2264,147 @@ window.__ModuleLoader__.load({
1530
2264
  return () => document.removeEventListener('keydown', onKeyDown, true)
1531
2265
  }, [selectedId])
1532
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.
1533
2269
  React.useEffect(() => {
1534
- setLimits({})
1535
- }, [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])
1536
2331
 
1537
- const rows = state?.sessions ?? []
1538
- 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
1539
2335
  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,
2336
+ () => composeGroups({
2337
+ overview,
2338
+ pages,
2339
+ sizes,
2340
+ expanded,
2341
+ defaultExpanded,
2342
+ query,
1546
2343
  tab,
1547
2344
  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],
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],
1555
2362
  )
1556
2363
  React.useEffect(() => {
1557
2364
  setSelected((previous) => {
1558
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
+ }
1559
2371
  const next = new Set()
1560
- 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)
1561
2373
  return next.size === previous.size ? previous : next
1562
2374
  })
1563
- }, [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) : '—'
1564
2408
 
1565
2409
  const run = async (operation, successText) => {
1566
2410
  setBusy(true)
@@ -1568,7 +2412,7 @@ window.__ModuleLoader__.load({
1568
2412
  try {
1569
2413
  const value = await operation()
1570
2414
  setNotice({ tone: 'ok', text: successText(value) })
1571
- await load({ silent: true })
2415
+ await refresh()
1572
2416
  return value
1573
2417
  } catch (failure) {
1574
2418
  setNotice({ tone: 'error', text: errorText(t, failure), detail: failure?.message })
@@ -1579,9 +2423,9 @@ window.__ModuleLoader__.load({
1579
2423
  }
1580
2424
 
1581
2425
  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 })
2426
+ const valueTotals = value?.totals
2427
+ if (valueTotals === undefined) return t('working')
2428
+ return t('batchDone', { ok: valueTotals.succeeded, failed: valueTotals.failed })
1585
2429
  }
1586
2430
 
1587
2431
  const archiveRows = async (ids, stopActivity = false) => {
@@ -1605,11 +2449,10 @@ window.__ModuleLoader__.load({
1605
2449
  }
1606
2450
 
1607
2451
  const moveRows = async (ids, workspaceId) => {
1608
- const value = await run(
2452
+ return await run(
1609
2453
  () => post('move', { sessionIds: ids, workspaceId }),
1610
2454
  (result) => resultText(result),
1611
2455
  )
1612
- return value
1613
2456
  }
1614
2457
 
1615
2458
  /**
@@ -1620,15 +2463,14 @@ window.__ModuleLoader__.load({
1620
2463
  */
1621
2464
  const deleteRows = async (ids) => {
1622
2465
  if (ids.length === 0) return undefined
1623
- const max = state?.limits?.maxBatch ?? 50
1624
2466
  setBusy(true)
1625
2467
  setNotice(null)
1626
2468
  try {
1627
2469
  let succeeded = 0
1628
2470
  let failed = 0
1629
2471
  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 })
2472
+ for (let index = 0; index < ids.length; index += maxBatch) {
2473
+ const value = await post('delete', { sessionIds: ids.slice(index, index + maxBatch), confirm: true })
1632
2474
  succeeded += value?.totals?.succeeded ?? 0
1633
2475
  failed += value?.totals?.failed ?? 0
1634
2476
  for (const item of value?.results ?? []) if (item.ok === false) failures.push(item)
@@ -1641,7 +2483,7 @@ window.__ModuleLoader__.load({
1641
2483
  setBulkDeleteIds(null)
1642
2484
  setSelected(new Set())
1643
2485
  if (selectedId !== null && ids.includes(selectedId)) setSelectedId(null)
1644
- await load({ silent: true })
2486
+ await refresh()
1645
2487
  return { totals: { succeeded, failed } }
1646
2488
  } catch (failure) {
1647
2489
  setNotice({ tone: 'error', text: errorText(t, failure), detail: failure?.message })
@@ -1652,19 +2494,18 @@ window.__ModuleLoader__.load({
1652
2494
  }
1653
2495
 
1654
2496
  const cleanupOrphans = async () => {
1655
- const ids = state?.orphans ?? []
2497
+ const ids = overview?.orphans ?? []
1656
2498
  if (ids.length === 0) return
1657
- const max = state?.limits?.maxBatch ?? 50
1658
2499
  setBusy(true)
1659
2500
  setNotice(null)
1660
2501
  try {
1661
2502
  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)
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)
1665
2506
  }
1666
2507
  setNotice({ tone: 'ok', text: t('cleanedOrphans', { n: cleaned }) })
1667
- await load({ silent: true })
2508
+ await refresh()
1668
2509
  } catch (failure) {
1669
2510
  setNotice({ tone: 'error', text: errorText(t, failure) })
1670
2511
  } finally {
@@ -1678,14 +2519,13 @@ window.__ModuleLoader__.load({
1678
2519
  */
1679
2520
  const archiveBlankSessions = async (ids) => {
1680
2521
  if (ids.length === 0) return
1681
- const max = state?.limits?.maxBatch ?? 50
1682
2522
  setBusy(true)
1683
2523
  setNotice(null)
1684
2524
  try {
1685
2525
  let archived = 0
1686
2526
  const failures = []
1687
- for (let index = 0; index < ids.length; index += max) {
1688
- const value = await post('archive', { sessionIds: ids.slice(index, index + max) })
2527
+ for (let index = 0; index < ids.length; index += maxBatch) {
2528
+ const value = await post('archive', { sessionIds: ids.slice(index, index + maxBatch) })
1689
2529
  archived += value?.totals?.succeeded ?? 0
1690
2530
  for (const item of value?.results ?? []) if (item.ok === false) failures.push(item)
1691
2531
  }
@@ -1698,7 +2538,7 @@ window.__ModuleLoader__.load({
1698
2538
  details: failures.slice(0, 20),
1699
2539
  })
1700
2540
  }
1701
- await load({ silent: true })
2541
+ await refresh()
1702
2542
  } catch (failure) {
1703
2543
  setNotice({ tone: 'error', text: errorText(t, failure) })
1704
2544
  } finally {
@@ -1727,44 +2567,21 @@ window.__ModuleLoader__.load({
1727
2567
  if (value !== undefined) openSession(id)
1728
2568
  }
1729
2569
 
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') })))
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)
1741
2575
  }
1742
2576
 
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
- })
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
+ }
1768
2585
 
1769
2586
  const toggle = (id) => {
1770
2587
  setSelected((previous) => {
@@ -1803,16 +2620,44 @@ window.__ModuleLoader__.load({
1803
2620
  const expandAll = () => setExpanded(new Set(view.groups.map((group) => group.id)))
1804
2621
  const collapseAll = () => setExpanded(new Set())
1805
2622
  const loadMore = (id) => {
1806
- 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') })))
1807
2641
  }
1808
- const isSearch = query.trim() !== ''
1809
2642
 
1810
2643
  if (selectedId !== null) {
1811
- const row = state.sessions.find((item) => item.id === selectedId)
2644
+ const row = detail ?? rowCache.get(selectedId)
1812
2645
  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')),
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,
1816
2661
  )
1817
2662
  }
1818
2663
  return h(
@@ -1822,13 +2667,14 @@ window.__ModuleLoader__.load({
1822
2667
  h(DetailView, {
1823
2668
  ctx,
1824
2669
  t,
1825
- state,
2670
+ overview,
2671
+ limits,
1826
2672
  row,
1827
2673
  busy,
1828
2674
  onBack: backToList,
1829
2675
  onChanged: (text) => {
1830
2676
  setNotice({ tone: 'ok', text: text ?? t('copied') })
1831
- void load({ silent: true })
2677
+ void refresh()
1832
2678
  },
1833
2679
  onOpen: openSession,
1834
2680
  onArchive: (id) => void archiveRows([id]),
@@ -1850,51 +2696,62 @@ window.__ModuleLoader__.load({
1850
2696
  { className: 'dsm-head-row' },
1851
2697
  h('h2', { className: 'dsm-title' }, t('pageTitle')),
1852
2698
  h('div', { className: 'dsm-head-actions' },
1853
- blankUnarchivedRows.length > 0
2699
+ blankIds.unarchivedIds.length > 0
1854
2700
  ? h('button', {
1855
2701
  type: 'button',
1856
2702
  className: 'dsm-btn',
1857
2703
  disabled: busy,
1858
2704
  title: t('archiveBlankHint'),
1859
- onClick: () => void archiveBlankSessions(blankUnarchivedRows.map((row) => row.id)),
1860
- }, t('archiveBlank', { n: blankUnarchivedRows.length }))
2705
+ onClick: () => void archiveBlankSessions(blankIds.unarchivedIds),
2706
+ }, t('archiveBlank', { n: blankIds.unarchivedIds.length }))
1861
2707
  : null,
1862
- blankArchivedRows.length > 0
2708
+ blankIds.archivedIds.length > 0
1863
2709
  ? h('button', {
1864
2710
  type: 'button',
1865
2711
  className: 'dsm-btn',
1866
- disabled: busy || blankDeletableRows.length === 0,
1867
- title: blankDeletableRows.length === blankArchivedRows.length
2712
+ disabled: busy || blankIds.deletableIds.length === 0,
2713
+ title: blankIds.deletableIds.length === blankIds.archivedIds.length
1868
2714
  ? t('cleanupBlankHint')
1869
- : t('cleanupBlankLockedHint', { n: blankArchivedRows.length - blankDeletableRows.length }),
1870
- onClick: () => setBulkDeleteIds(blankDeletableRows.map((row) => row.id)),
1871
- }, t('cleanupBlank', { n: blankArchivedRows.length }))
2715
+ : t('cleanupBlankLockedHint', { n: blankIds.archivedIds.length - blankIds.deletableIds.length }),
2716
+ onClick: () => setBulkDeleteIds(blankIds.deletableIds),
2717
+ }, t('cleanupBlank', { n: blankIds.archivedIds.length }))
1872
2718
  : null,
1873
- h('button', { type: 'button', className: 'dsm-btn', disabled: busy, onClick: () => void load({ silent: true }) },
2719
+ h('button', { type: 'button', className: 'dsm-btn', disabled: busy, onClick: () => void refresh() },
1874
2720
  busy ? h(Spinner, { t, label: t('working') }) : t('refresh')),
1875
2721
  ),
1876
2722
  ),
1877
2723
  h('p', { className: 'dsm-sub' }, t('pageSubtitle')),
1878
2724
  h('p', { className: 'dsm-sub' },
1879
2725
  t('totalLine', {
1880
- all: state.totals.all,
1881
- archived: state.totals.archived,
1882
- size: formatBytes(state.totals.bytes),
2726
+ all: totals.all ?? 0,
2727
+ archived: totals.archived ?? 0,
2728
+ size: sizeText,
1883
2729
  }),
1884
- blankRows.length > 0 && !showBlank
1885
- ? ` · ${t('hiddenBlankCount', { n: blankRows.length })}`
2730
+ (totals.blank ?? 0) > 0 && !showBlank
2731
+ ? ` · ${t('hiddenBlankCount', { n: totals.blank })}`
1886
2732
  : '',
1887
- (state.totals?.hiddenSubagents ?? 0) > 0 && !includeSubagents
1888
- ? ` · ${t('hiddenSubagentCount', { n: state.totals.hiddenSubagents })}`
2733
+ (totals.hiddenSubagents ?? 0) > 0 && !includeSubagents
2734
+ ? ` · ${t('hiddenSubagentCount', { n: totals.hiddenSubagents })}`
1889
2735
  : '',
2736
+ sizesPhase === 'loading' ? ` · ${t('sizesPending')}` : '',
1890
2737
  ),
1891
2738
  ),
1892
2739
  h(Notice, { notice, t, onDismiss: () => setNotice(null) }),
1893
- state.orphans.length > 0
2740
+ overviewPhase === 'error'
1894
2741
  ? h(
1895
2742
  'div',
1896
2743
  { className: 'dsm-note warn' },
1897
- h('div', { style: { flex: '1 1 auto' } }, t('orphanBanner', { n: state.orphans.length })),
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
2751
+ ? h(
2752
+ 'div',
2753
+ { className: 'dsm-note warn' },
2754
+ h('div', { style: { flex: '1 1 auto' } }, t('orphanBanner', { n: overview.orphans.length })),
1898
2755
  h('div', { className: 'dsm-note-actions' },
1899
2756
  h('button', { type: 'button', className: 'dsm-btn', disabled: busy, onClick: () => void cleanupOrphans() },
1900
2757
  busy ? t('cleaningOrphans') : t('cleanupOrphans')),
@@ -1902,139 +2759,142 @@ window.__ModuleLoader__.load({
1902
2759
  )
1903
2760
  : null,
1904
2761
  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}`),
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
+ ),
1917
2775
  ),
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')}`}`),
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')),
1929
2788
  ),
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
2789
  h('input', {
1962
- type: 'checkbox',
1963
- checked: showBlank,
1964
- 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),
1965
2797
  }),
1966
- 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
+ ),
1967
2825
  ),
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),
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
+ ),
2006
2867
  ),
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,
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,
2038
2898
  ),
2039
2899
  h('div', { className: 'dsm-list-actions' },
2040
2900
  h('button', {
@@ -2051,19 +2911,29 @@ window.__ModuleLoader__.load({
2051
2911
  }, t('collapseAll')),
2052
2912
  isSearch ? h('span', { className: 'dsm-hint' }, t('searchAutoExpand')) : null,
2053
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,
2054
2924
  bulkDeleteIds !== null
2055
2925
  ? h(DeleteScope, {
2056
2926
  ctx,
2057
2927
  t,
2058
2928
  ids: bulkDeleteIds,
2059
- maxBatch: state.limits?.maxBatch ?? 50,
2929
+ maxBatch,
2060
2930
  busy,
2061
2931
  onCancel: () => setBulkDeleteIds(null),
2062
2932
  onConfirmed: (ids) => void deleteRows(ids),
2063
2933
  })
2064
2934
  : null,
2065
- view.groups.length > 0
2066
- ? view.groups.map((group) =>
2935
+ rows.length > 0
2936
+ ? rows.map((group) =>
2067
2937
  h(Group, {
2068
2938
  key: group.id,
2069
2939
  group,
@@ -2072,11 +2942,14 @@ window.__ModuleLoader__.load({
2072
2942
  onSelect: toggle,
2073
2943
  onOpen: openDetail,
2074
2944
  onMore: () => loadMore(group.id),
2945
+ onRetry: () => retryGroup(group.id),
2075
2946
  locked: isSearch,
2076
2947
  t,
2077
2948
  }),
2078
2949
  )
2079
- : 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')),
2080
2953
  h('p', { className: 'dsm-footer' }, t('archiveHint')),
2081
2954
  )
2082
2955
  }
@@ -2084,6 +2957,24 @@ window.__ModuleLoader__.load({
2084
2957
 
2085
2958
  return {
2086
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
+ },
2087
2978
  apply(ctx) {
2088
2979
  const t = ctx.locale.bind(NS)
2089
2980
  ctx.effect(() => ctx.locale.register(NS, DICT), 'session-mng: dictionaries')