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