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