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/CHANGELOG.md +34 -0
- package/README.md +22 -8
- package/ROADMAP.md +12 -11
- package/client.js +1210 -319
- package/docs/01-design.md +49 -2
- package/docs/02-open-source-and-marketplace.md +16 -0
- package/docs/HANDOVER-2026-10-29.md +105 -0
- package/docs/PLAN-2026-10-29.md +245 -0
- package/docs/RELEASE-CHECKLIST-0.1.4.md +6 -5
- package/docs/RELEASE-CHECKLIST-0.2.0.md +76 -0
- package/lib/api.js +44 -11
- package/lib/catalog.js +773 -0
- package/lib/service.js +456 -0
- package/lib/state.js +46 -25
- package/lib/store-jsonl.js +76 -2
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
|
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' },
|
|
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 &&
|
|
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,
|
|
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 = (
|
|
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:
|
|
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
|
-
|
|
1375
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
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
|
|
1403
|
-
if (
|
|
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
|
|
1407
|
-
bytes:
|
|
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
|
-
|
|
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
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
const
|
|
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
|
-
|
|
1464
|
-
|
|
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
|
-
|
|
1467
|
-
|
|
1904
|
+
sizesAbort.current = controller
|
|
1905
|
+
setSizesPhase('loading')
|
|
1468
1906
|
try {
|
|
1469
|
-
const value = await
|
|
1907
|
+
const value = await getSizes('all', includeSubagentsRef.current, controller.signal)
|
|
1470
1908
|
if (controller.signal.aborted) return
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
setError(null)
|
|
1909
|
+
setSizes(normalizeSizes(value))
|
|
1910
|
+
setSizesPhase('ready')
|
|
1474
1911
|
} catch (failure) {
|
|
1475
1912
|
if (failure?.name === 'AbortError') return
|
|
1476
|
-
|
|
1477
|
-
|
|
1913
|
+
setSizes(null)
|
|
1914
|
+
setSizesPhase('error')
|
|
1478
1915
|
}
|
|
1479
|
-
}
|
|
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
|
|
1483
|
-
return () =>
|
|
1484
|
-
|
|
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
|
|
2168
|
+
const onFocus = () => void loadOverview({ silent: true })
|
|
1488
2169
|
window.addEventListener('focus', onFocus)
|
|
1489
2170
|
return () => window.removeEventListener('focus', onFocus)
|
|
1490
|
-
}, [
|
|
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
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
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
|
|
1500
|
-
const parent = scrollParentOf(
|
|
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
|
-
|
|
1535
|
-
|
|
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
|
|
1538
|
-
const
|
|
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
|
-
() =>
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
2336
|
+
() => composeGroups({
|
|
2337
|
+
overview,
|
|
2338
|
+
pages,
|
|
2339
|
+
sizes,
|
|
2340
|
+
expanded,
|
|
2341
|
+
defaultExpanded,
|
|
2342
|
+
query,
|
|
1546
2343
|
tab,
|
|
1547
2344
|
workspaceFilter,
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
[
|
|
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 (
|
|
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
|
-
}, [
|
|
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
|
|
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
|
|
1583
|
-
if (
|
|
1584
|
-
return t('batchDone', { ok:
|
|
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
|
-
|
|
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 +=
|
|
1631
|
-
const value = await post('delete', { sessionIds: ids.slice(index, index +
|
|
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
|
|
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 =
|
|
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 +=
|
|
1663
|
-
await post('unarchive', { sessionIds: ids.slice(index, index +
|
|
1664
|
-
cleaned += Math.min(
|
|
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
|
|
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 +=
|
|
1688
|
-
const value = await post('archive', { sessionIds: ids.slice(index, index +
|
|
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
|
|
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
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
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
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
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
|
-
|
|
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 =
|
|
2644
|
+
const row = detail ?? rowCache.get(selectedId)
|
|
1812
2645
|
if (row === undefined) {
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
1860
|
-
}, t('archiveBlank', { n:
|
|
2705
|
+
onClick: () => void archiveBlankSessions(blankIds.unarchivedIds),
|
|
2706
|
+
}, t('archiveBlank', { n: blankIds.unarchivedIds.length }))
|
|
1861
2707
|
: null,
|
|
1862
|
-
|
|
2708
|
+
blankIds.archivedIds.length > 0
|
|
1863
2709
|
? h('button', {
|
|
1864
2710
|
type: 'button',
|
|
1865
2711
|
className: 'dsm-btn',
|
|
1866
|
-
disabled: busy ||
|
|
1867
|
-
title:
|
|
2712
|
+
disabled: busy || blankIds.deletableIds.length === 0,
|
|
2713
|
+
title: blankIds.deletableIds.length === blankIds.archivedIds.length
|
|
1868
2714
|
? t('cleanupBlankHint')
|
|
1869
|
-
: t('cleanupBlankLockedHint', { n:
|
|
1870
|
-
onClick: () => setBulkDeleteIds(
|
|
1871
|
-
}, t('cleanupBlank', { n:
|
|
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
|
|
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:
|
|
1881
|
-
archived:
|
|
1882
|
-
size:
|
|
2726
|
+
all: totals.all ?? 0,
|
|
2727
|
+
archived: totals.archived ?? 0,
|
|
2728
|
+
size: sizeText,
|
|
1883
2729
|
}),
|
|
1884
|
-
|
|
1885
|
-
? ` · ${t('hiddenBlankCount', { n:
|
|
2730
|
+
(totals.blank ?? 0) > 0 && !showBlank
|
|
2731
|
+
? ` · ${t('hiddenBlankCount', { n: totals.blank })}`
|
|
1886
2732
|
: '',
|
|
1887
|
-
(
|
|
1888
|
-
? ` · ${t('hiddenSubagentCount', { n:
|
|
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
|
-
|
|
2740
|
+
overviewPhase === 'error'
|
|
1894
2741
|
? h(
|
|
1895
2742
|
'div',
|
|
1896
2743
|
{ className: 'dsm-note warn' },
|
|
1897
|
-
h('div', { style: { flex: '1 1 auto' } }, t('
|
|
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
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
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
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
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
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
h('option', {
|
|
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
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
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
|
|
2929
|
+
maxBatch,
|
|
2060
2930
|
busy,
|
|
2061
2931
|
onCancel: () => setBulkDeleteIds(null),
|
|
2062
2932
|
onConfirmed: (ids) => void deleteRows(ids),
|
|
2063
2933
|
})
|
|
2064
2934
|
: null,
|
|
2065
|
-
|
|
2066
|
-
?
|
|
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
|
-
:
|
|
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')
|