dsh-long-archive 2.0.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/LICENSE +21 -0
- package/README.md +101 -0
- package/cordis.patch.yml +13 -0
- package/lib/client.js +1162 -0
- package/lib/index.js +327 -0
- package/package.json +65 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,1162 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-long-archive",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react = require("react");
|
|
8
|
+
let dsw = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
9
|
+
|
|
10
|
+
const css = `
|
|
11
|
+
[class*="_root"]:has(> [class*="_footArea"]){position:relative}
|
|
12
|
+
.arcv-panel{pointer-events:auto;position:absolute;top:120px;bottom:0;left:0;right:0;z-index:70;box-sizing:border-box;padding:0 12px;display:flex;flex-direction:column;background:var(--dsw-specific-sidebar-fill,var(--dsw-alias-bg-base,#0d1117));font-size:14px;color:var(--dsw-alias-label-primary,#e6edf3);animation:arcv-in .2s var(--ds-ease-in-out)}
|
|
13
|
+
@keyframes arcv-in{0%{opacity:0}}
|
|
14
|
+
.arcv-sectionHeader{box-sizing:border-box;height:36px;color:var(--dsw-alias-label-tertiary);border-radius:12px;flex:none;justify-content:flex-end;align-items:center;gap:4px;margin:2px 0 4px 0;padding:0 0 0 4px;display:flex;overflow:hidden}
|
|
15
|
+
.arcv-sectionLabel{white-space:nowrap;min-width:0;line-height:20px;overflow:hidden}
|
|
16
|
+
.arcv-iconButton{cursor:pointer;width:28px;height:28px;color:var(--dsw-alias-label-secondary);background:0 0;border:none;border-radius:50%;flex:none;justify-content:center;align-items:center;padding:0;display:inline-flex}
|
|
17
|
+
.arcv-iconButton:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
18
|
+
.arcv-sectionLabelArcv{flex:none;max-width:45%;min-width:0;overflow:hidden;white-space:nowrap;line-height:20px;opacity:1;visibility:visible;transition:max-width 180ms var(--ds-ease-in-out),margin-right 180ms var(--ds-ease-in-out),opacity 120ms var(--ds-ease-in-out),transform 180ms var(--ds-ease-in-out),visibility 0s linear}
|
|
19
|
+
.arcv-sectionLabelHidden{max-width:0;margin-right:-4px;opacity:0;transform:translateX(-4px);visibility:hidden;transition-delay:0s,0s,0s,0s,180ms}
|
|
20
|
+
.arcv-searchSlot{flex:1;max-width:28px;min-width:0;display:flex;align-items:center;margin-left:auto;padding-left:0;box-sizing:border-box;transition:max-width 180ms var(--ds-ease-in-out),padding-left 180ms var(--ds-ease-in-out)}
|
|
21
|
+
.arcv-searchSlotExpanded{max-width:100%;padding-left:0}
|
|
22
|
+
.arcv-search{flex:none;display:flex;align-items:center;gap:0;width:100%;height:28px;margin:0;padding:0;box-sizing:border-box;border:none;border-radius:50%;background:transparent;cursor:text;color:var(--dsw-alias-label-secondary);overflow:hidden;transition:width 180ms var(--ds-ease-in-out),padding 180ms var(--ds-ease-in-out),border-color 180ms var(--ds-ease-in-out),background-color 180ms var(--ds-ease-in-out)}
|
|
23
|
+
.arcv-searchExpanded{width:calc(100% + 4px);height:30px;margin-inline:-2px;padding:0 4px 0 0;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:transparent;color:var(--dsw-alias-label-caption)}
|
|
24
|
+
.arcv-searchButton{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:50%;padding:0;background:transparent;cursor:pointer;color:inherit}
|
|
25
|
+
.arcv-searchExpanded .arcv-searchButton{width:28px;height:30px}
|
|
26
|
+
.arcv-searchButton:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
27
|
+
.arcv-searchExpanded .arcv-searchButton:hover{background:transparent}
|
|
28
|
+
.arcv-searchInput{flex:1;width:0;min-width:0;border:none;outline:none;background:transparent;opacity:0;pointer-events:none;font-size:13px;line-height:18px;color:var(--dsw-alias-label-primary);transition:opacity 120ms var(--ds-ease-in-out)}
|
|
29
|
+
.arcv-searchExpanded .arcv-searchInput{margin-left:-2px;opacity:1;pointer-events:auto}
|
|
30
|
+
.arcv-searchInput::placeholder{color:var(--dsw-alias-label-tertiary)}
|
|
31
|
+
.arcv-clearButton{flex:none;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border:none;border-radius:50%;padding:0;background:transparent;cursor:pointer;color:var(--dsw-alias-label-secondary)}
|
|
32
|
+
.arcv-clearButton:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
33
|
+
.arcv-headerActions{flex:none;display:flex;align-items:center;gap:4px;max-width:60px;opacity:1;overflow:hidden;visibility:visible;transition:max-width 180ms var(--ds-ease-in-out),opacity 120ms var(--ds-ease-in-out),transform 180ms var(--ds-ease-in-out),visibility 0s linear}
|
|
34
|
+
.arcv-headerActionsHidden{max-width:0;opacity:0;transform:translateX(4px);visibility:hidden;pointer-events:none;transition-delay:0s,0s,0s,180ms}
|
|
35
|
+
.arcv-menuCard{box-sizing:border-box;padding:4px;display:flex;flex-direction:column;gap:0;border:1px solid var(--dsw-alias-border-inverted);border-radius:12px;background:var(--dsw-specific-menu,#1c2128);box-shadow:var(--dsw-shadow-lv3);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);min-width:218px;max-width:360px}
|
|
36
|
+
.arcv-menuItem{display:flex;align-items:center;gap:8px;width:100%;min-height:34px;padding:5px 10px;border:none;border-radius:10px;background:transparent;cursor:pointer;font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary);text-align:left;font-family:inherit}
|
|
37
|
+
.arcv-menuItem:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
|
|
38
|
+
.arcv-menuItemIcon{display:inline-flex;flex:none;width:16px;height:16px;align-items:center;justify-content:center;color:var(--dsw-alias-label-tertiary)}
|
|
39
|
+
.arcv-menuItemLabel{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
40
|
+
.arcv-menuCheck{flex:none;color:var(--dsw-alias-label-primary)}
|
|
41
|
+
.arcv-menuLabel{padding:8px 10px;font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary)}
|
|
42
|
+
.arcv-menuLabelDense{padding-block:4px}
|
|
43
|
+
.arcv-menuSeparator{height:1px;margin:4px 2px;background:var(--dsw-alias-border-l1)}
|
|
44
|
+
.arcv-sessionRow.arcv-dropBefore::before,.arcv-sessionRow.arcv-dropAfter::after{content:'';position:absolute;z-index:1;left:0;right:4px;height:12px;background:linear-gradient(55deg,transparent calc(50% - 1px),var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px),transparent calc(50% + 1px)) 0 0/5px 7px no-repeat,linear-gradient(125deg,transparent calc(50% - 1px),var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px),transparent calc(50% + 1px)) 0 5px/5px 7px no-repeat,linear-gradient(var(--dsw-alias-state-business-primary) 0 0) 4px 5px/calc(100% - 4px) 2px no-repeat;pointer-events:none}
|
|
45
|
+
.arcv-sessionRow.arcv-dropBefore::before{top:-7px}
|
|
46
|
+
.arcv-sessionRow.arcv-dropAfter::after{bottom:-7px}
|
|
47
|
+
.arcv-sessionRow{position:relative}
|
|
48
|
+
.arcv-menuPortal{position:fixed;z-index:1100}
|
|
49
|
+
.arcv-searchPending{color:var(--dsw-alias-label-tertiary);padding:10px 12px;font-size:12px;line-height:18px}
|
|
50
|
+
.arcv-searchWarning{color:var(--dsw-alias-label-secondary);padding:10px 12px;font-size:12px;line-height:18px}
|
|
51
|
+
.arcv-searchResultRow{display:flex;flex-direction:column;align-items:stretch;width:100%;min-height:48px;box-sizing:border-box;border:none;border-radius:8px;padding:4px 8px;background:transparent;cursor:pointer;text-align:left;color:var(--dsw-alias-label-primary);font-family:inherit}
|
|
52
|
+
.arcv-searchResultRow:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
53
|
+
.arcv-searchResultRow.arcv-selected{background:var(--dsw-alias-interactive-bg-hover)}
|
|
54
|
+
.arcv-searchResultHeading{display:flex;align-items:center;min-width:0}
|
|
55
|
+
.arcv-searchResultTitle{min-width:0;margin-left:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px;line-height:20px}
|
|
56
|
+
.arcv-searchResultMeta{display:flex;align-items:center;gap:6px;min-width:0;margin-left:20px}
|
|
57
|
+
.arcv-searchResultWorkspace{flex:none;max-width:40%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:17px;color:var(--dsw-alias-label-tertiary)}
|
|
58
|
+
.arcv-searchResultSnippet{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:17px;color:var(--dsw-alias-label-secondary)}
|
|
59
|
+
.arcv-list{margin-left:-4px;padding-left:4px;padding-right:2px;scrollbar-gutter:stable;flex:1;padding-bottom:16px;overflow-y:auto;display:flex;flex-direction:column}
|
|
60
|
+
.arcv-groupSection{position:relative}
|
|
61
|
+
.arcv-groupSection+.arcv-groupSection{margin-top:4px}
|
|
62
|
+
.arcv-groupSection>*+*{margin-top:2px}
|
|
63
|
+
.arcv-projectRow,.arcv-sessionRow{cursor:pointer;user-select:none;color:var(--dsw-alias-label-primary);border-radius:8px;align-items:center;gap:6px;padding:0 8px;display:flex}
|
|
64
|
+
.arcv-projectRow:hover,.arcv-sessionRow:hover,.arcv-sessionRow.arcv-selected{background:var(--dsw-alias-interactive-bg-hover)}
|
|
65
|
+
.arcv-projectRow{box-sizing:border-box;align-items:center;height:34px}
|
|
66
|
+
.arcv-sessionRow{height:32px;animation:arcv-row-in .15s var(--ds-ease-in-out);gap:0}
|
|
67
|
+
@keyframes arcv-row-in{0%{opacity:0}}
|
|
68
|
+
.arcv-sessionRow .arcv-title{margin:0 6px 0 4px;flex:1}
|
|
69
|
+
.arcv-sessionRow.arcv-flatNoStatus .arcv-title{margin-left:0}
|
|
70
|
+
.arcv-slot{width:16px;height:20px;color:var(--dsw-alias-label-tertiary);flex:none;justify-content:center;align-items:center;display:inline-flex}
|
|
71
|
+
.arcv-folderActive{color:var(--dsw-alias-state-business-primary)}
|
|
72
|
+
.arcv-projectText{flex-direction:column;flex:1;gap:2px;min-width:0;display:flex}
|
|
73
|
+
.arcv-title{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:14px;line-height:20px;overflow:hidden}
|
|
74
|
+
.arcv-time{color:var(--dsw-alias-label-tertiary);flex:none;font-size:12px;line-height:20px}
|
|
75
|
+
.arcv-rowActions{flex:none;align-items:center;gap:6px;display:none}
|
|
76
|
+
.arcv-sessionRow:hover .arcv-rowActions,.arcv-sessionRow.arcv-selected .arcv-rowActions{display:inline-flex}
|
|
77
|
+
.arcv-sessionRow:hover .arcv-time,.arcv-sessionRow.arcv-selected .arcv-time{display:none}
|
|
78
|
+
.arcv-iconRow{cursor:pointer;width:16px;height:16px;color:var(--dsw-alias-label-tertiary);background:0 0;border:none;border-radius:4px;flex:none;justify-content:center;align-items:center;padding:0;display:inline-flex}
|
|
79
|
+
.arcv-iconRow:hover{color:var(--dsw-alias-label-primary)}
|
|
80
|
+
.arcv-restoreAction{margin-left:2px}
|
|
81
|
+
.arcv-restoreAction:hover{color:var(--dsw-alias-state-business-primary)}
|
|
82
|
+
.arcv-dangerAction:hover{color:var(--dsw-alias-state-error-primary)}
|
|
83
|
+
.arcv-empty{color:var(--dsw-alias-label-tertiary);padding:16px 12px;font-size:13px}
|
|
84
|
+
.arcv-error{color:var(--dsw-alias-state-error-primary);font-size:12px;padding:6px 12px;margin:0 8px 6px;border-radius:6px;background:rgba(248,81,73,.08);cursor:pointer}
|
|
85
|
+
.arcv-hint{color:var(--dsw-alias-label-secondary);font-size:12px;padding:6px 12px;margin:0 8px 6px;border-radius:6px;background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));cursor:pointer}
|
|
86
|
+
/* Negative bottom margin: the settings trigger's own rail margin-top (8px)
|
|
87
|
+
is the other half of the gap; -6px pulls the pair to a 2px visual gap. */
|
|
88
|
+
.arcv-footer-btn{box-sizing:border-box;cursor:pointer;width:36px;height:36px;color:var(--dsw-alias-label-primary);background:0 0;border:none;border-radius:50%;flex:none;justify-content:center;align-items:center;gap:0;margin:8px 0 -6px;padding:0;font-family:inherit;font-size:14px;line-height:22px;display:inline-flex;overflow:hidden}
|
|
89
|
+
.arcv-footer-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
90
|
+
.arcv-footer-label{white-space:nowrap;overflow:hidden}
|
|
91
|
+
[class*="_root"]:not([class*="_collapsed"]) .arcv-footer-btn{width:calc(100% + 8px);height:34px;border-radius:12px;justify-content:flex-start;gap:8px;margin:4px -4px -2px;padding:6px 2px 6px 10px}
|
|
92
|
+
.arcv-modal-backdrop{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;padding:24px}
|
|
93
|
+
.arcv-modal-mask{position:absolute;inset:0;background:var(--dsw-alias-bg-mask-1);backdrop-filter:var(--dsw-mask-blur)}
|
|
94
|
+
.arcv-modal{position:relative;z-index:1;box-sizing:border-box;display:flex;flex-direction:column;gap:12px;width:min(360px,100%);padding:0 0 14px;overflow:hidden;border:1px solid var(--dsw-alias-border-inverted);border-radius:24px;background:var(--dsw-alias-bg-layer-2);box-shadow:var(--dsw-shadow-lv3)}
|
|
95
|
+
.arcv-modal-header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:16px 12px 8px 20px}
|
|
96
|
+
.arcv-modal-title{margin:0;font-size:16px;line-height:24px;font-weight:600;color:var(--dsw-alias-label-primary)}
|
|
97
|
+
.arcv-modal-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:8px;background:transparent;cursor:pointer;color:var(--dsw-alias-label-secondary);padding:0}
|
|
98
|
+
.arcv-modal-close:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
99
|
+
.arcv-modal-desc{margin:0;padding:0 20px;font-size:14px;line-height:22px;font-weight:400;color:var(--dsw-alias-label-secondary)}
|
|
100
|
+
.arcv-modal-status{margin-top:12px;padding:0 20px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}
|
|
101
|
+
.arcv-modal-error{margin-top:12px;padding:6px 20px 0;font-size:12px;line-height:18px;color:var(--dsw-alias-state-error-primary)}
|
|
102
|
+
.arcv-modal-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:0 20px}
|
|
103
|
+
.arcv-modal-btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;height:30px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;cursor:pointer;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-active);padding:0 14px;font-family:inherit}
|
|
104
|
+
.arcv-modal-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
|
|
105
|
+
.arcv-modal-btn:disabled{opacity:.4;cursor:not-allowed}
|
|
106
|
+
.arcv-modal-btn-danger{background:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);color:#ffffff}
|
|
107
|
+
.arcv-modal-btn-danger:hover:not(:disabled){background:var(--dsw-alias-state-error-primary);filter:brightness(1.08)}
|
|
108
|
+
.arcv-modal-btn-danger:not(:disabled) .arcv-modal-kbd{color:rgba(255,255,255,.72)}
|
|
109
|
+
.arcv-modal-kbd{font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary);flex:none;user-select:none}
|
|
110
|
+
@media (prefers-reduced-motion:reduce){.arcv-sessionRow,.arcv-panel{transition:none;animation:none}}
|
|
111
|
+
`
|
|
112
|
+
|
|
113
|
+
const client = {
|
|
114
|
+
name: 'archive-manager',
|
|
115
|
+
// Load contract deliberately kept at the pre-review shape ('slots'
|
|
116
|
+
// only): the 4-service waiting declaration (review M1) is the prime
|
|
117
|
+
// suspect for the entry disappearing in the real browser — the panel
|
|
118
|
+
// reads the other services defensively at apply time instead.
|
|
119
|
+
inject: ['slots', 'sessions', 'workspaces', 'locale', 'layout'],
|
|
120
|
+
apply(ctx) {
|
|
121
|
+
// 防崩:客户端任何异常都不要冒泡(最坏只是入口/面板不出现)。
|
|
122
|
+
try {
|
|
123
|
+
const slots = ctx.slots
|
|
124
|
+
const sessions = ctx.get('sessions')
|
|
125
|
+
const workspaces = ctx.get('workspaces')
|
|
126
|
+
const locale = ctx.get('locale')
|
|
127
|
+
// Official connection RPC channel ('/arcv', loopback authority on the
|
|
128
|
+
// host side): the platform fence replaces the private /__arcv route.
|
|
129
|
+
// Transport/HTTP failures collapse to a stable code for hostError().
|
|
130
|
+
// 0.1.5: 走插件自己的 HTTP 路由(不再用 connection.rpc)
|
|
131
|
+
const hostCall = (method, args) => fetch('/api/dsh-archive-rpc', {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
headers: { 'content-type': 'application/json' },
|
|
134
|
+
body: JSON.stringify({ endpoint: method, payload: args ?? {} }),
|
|
135
|
+
}).then((r) => r.json()).catch(() => ({ ok: false, code: 'network' }))
|
|
136
|
+
const cleanups = []
|
|
137
|
+
const styleEl = document.createElement('style')
|
|
138
|
+
styleEl.textContent = css
|
|
139
|
+
document.head.appendChild(styleEl)
|
|
140
|
+
const h = react.createElement
|
|
141
|
+
|
|
142
|
+
// Follow the system locale: register zh/en dictionaries and bind the translator.
|
|
143
|
+
// register() returns a removal disposer — held so unload retires the
|
|
144
|
+
// dictionaries (a dropped disposer would leave them resident forever).
|
|
145
|
+
let t = (key, params) => key
|
|
146
|
+
if (locale !== undefined) {
|
|
147
|
+
const NS = 'arcv-archive'
|
|
148
|
+
const zh = { 'footer': '已归档', 'section': '归档区', 'close': '关闭', 'close.aria': '关闭归档视图', 'restore': '恢复', 'restore.aria': '恢复会话“{name}”', 'delete': '删除', 'delete.aria': '删除会话“{name}”', 'empty': '没有已归档的会话', 'empty.noMatches': '无匹配结果', 'ungrouped': '未分组', 'delete.title': '删除会话', 'delete.desc': '将永久删除会话“{name}”及其全部记录,此操作不可撤销。', 'cancel': '取消', 'delete.pending': '正在删除会话…', 'error.dismiss': '(点击关闭)', 'time.now': '刚刚', 'time.minutes': '{n} 分钟前', 'time.hours': '{n} 小时前', 'time.days': '{n} 天前', 'time.months': '{n} 个月前', 'time.years': '{n} 年前', 'status.running': '运行中', 'status.completed': '已完成', 'status.waitingApproval': '等待批准', 'status.planReview': '等待计划审阅', 'status.waitingAnswer': '等待回答', 'err.still-live': '会话仍在本页打开,请先切换到其他会话再删除', 'err.no-captured-disposer': '无法停止该会话的代理(控制权已随插件重载丢失),请重启 dsh 后再删除', 'err.not-archived': '该会话不在归档区(可能已被其他页面处理),请刷新后重试', 'err.bad-session-id': '无效的会话标识', 'err.network': '网络异常,请重试', 'err.delete-pending': '该会话正在删除中,请稍候片刻再恢复', 'err.log-removal-failed': '日志删除失败,请重试', 'err.persistence-unavailable': '持久化服务不可用,无法删除日志', 'err.unknown': '操作失败,请重试', 'viewOptions.label': '视图选项', 'groupBy.label': '分组方式', 'groupBy.workspace': '按工作区', 'groupBy.flat': '单列表', 'orderBy.label': '排序方式', 'orderBy.manual': '手动排序', 'orderBy.updated': '最近更新', 'section.sessions': '会话', 'search.sessions.aria': '搜索会话', 'search.placeholder': '搜索会话…', 'search.clear': '清除搜索', 'search.pending': '正在搜索会话历史…', 'search.unavailable': '内容搜索暂不可用,仅显示名称匹配。' }
|
|
149
|
+
const en = { 'footer': 'Archived', 'section': 'Archive', 'close': 'Close', 'close.aria': 'Close archive view', 'restore': 'Restore', 'restore.aria': 'Restore session “{name}”', 'delete': 'Delete', 'delete.aria': 'Delete session “{name}”', 'empty': 'No archived sessions', 'empty.noMatches': 'No matches', 'ungrouped': 'Ungrouped', 'delete.title': 'Delete session', 'delete.desc': 'This permanently deletes session “{name}” and its full record. This cannot be undone.', 'cancel': 'Cancel', 'delete.pending': 'Deleting session…', 'error.dismiss': ' (click to dismiss)', 'time.now': 'Just now', 'time.minutes': '{n} min ago', 'time.hours': '{n} h ago', 'time.days': '{n} d ago', 'time.months': '{n} mo ago', 'time.years': '{n} y ago', 'status.running': 'Running', 'status.completed': 'Completed', 'status.waitingApproval': 'Waiting for approval', 'status.planReview': 'Waiting for plan review', 'status.waitingAnswer': 'Waiting for answer', 'err.still-live': 'Session is open in this page; switch to another session before deleting', 'err.no-captured-disposer': 'Cannot stop the session agent (control lost after a plugin reload); restart dsh and retry', 'err.not-archived': 'Session is no longer archived (possibly handled elsewhere); refresh and retry', 'err.bad-session-id': 'Invalid session id', 'err.network': 'Network error; please retry', 'err.delete-pending': 'This session is still being deleted; try restoring again shortly', 'err.log-removal-failed': 'Log removal failed; please retry', 'err.persistence-unavailable': 'Session persistence unavailable; cannot delete the log', 'err.unknown': 'Operation failed; please retry', 'viewOptions.label': 'View options', 'groupBy.label': 'Group by', 'groupBy.workspace': 'WorkSpace', 'groupBy.flat': 'In one list', 'orderBy.label': 'Order by', 'orderBy.manual': 'Manual', 'orderBy.updated': 'Recently updated', 'section.sessions': 'Sessions', 'search.sessions.aria': 'Search sessions', 'search.placeholder': 'Search sessions…', 'search.clear': 'Clear search', 'search.pending': 'Searching session history…', 'search.unavailable': 'Content search unavailable; showing name matches only.' }
|
|
150
|
+
try { cleanups.push(locale.register(NS, 'zh', zh)) } catch (e) {}
|
|
151
|
+
try { cleanups.push(locale.register(NS, 'en', en)) } catch (e) {}
|
|
152
|
+
t = locale.bind(NS)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Module-private pure function from the official client.js, copied verbatim: project() uses it to derive recentWorkspaceId.
|
|
156
|
+
function recentWorkspaceOf(wsItems, sessionsById) {
|
|
157
|
+
let selected
|
|
158
|
+
let selectedTime = Number.NEGATIVE_INFINITY
|
|
159
|
+
for (const workspace of wsItems) {
|
|
160
|
+
let latest = Number.NEGATIVE_INFINITY
|
|
161
|
+
for (const sessionId of workspace.sessionIds) {
|
|
162
|
+
const session = sessionsById[sessionId]
|
|
163
|
+
if (session !== undefined) latest = Math.max(latest, session.updatedAt)
|
|
164
|
+
}
|
|
165
|
+
if (latest === Number.NEGATIVE_INFINITY) latest = Date.parse(workspace.createdAt)
|
|
166
|
+
if (selected === undefined || latest > selectedTime) {
|
|
167
|
+
selected = workspace.workspaceId
|
|
168
|
+
selectedTime = latest
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return selected
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// The 41-file upstream fix: project() no longer runs the "archived current session → clear selection"
|
|
175
|
+
// sweep; the clear moves to the archive gesture instead.
|
|
176
|
+
const restorePatches = []
|
|
177
|
+
if (workspaces !== undefined && typeof workspaces.project === 'function') {
|
|
178
|
+
const originalProject = workspaces.project
|
|
179
|
+
workspaces.project = function patchedProject() {
|
|
180
|
+
try {
|
|
181
|
+
const workspace = this.manager.getSnapshot()
|
|
182
|
+
const sessionsSnap = this.sessions.list.getSnapshot()
|
|
183
|
+
const baselinesReady = workspace.phase === 'ready' && sessionsSnap.phase === 'ready'
|
|
184
|
+
this.list.set({
|
|
185
|
+
items: workspace.items,
|
|
186
|
+
archivedSessionIds: workspace.archivedSessionIds,
|
|
187
|
+
state: workspace.state,
|
|
188
|
+
phase: workspace.phase,
|
|
189
|
+
error: workspace.error,
|
|
190
|
+
baselinesReady,
|
|
191
|
+
recentWorkspaceId: baselinesReady ? recentWorkspaceOf(workspace.items, sessionsSnap.byId) : undefined,
|
|
192
|
+
})
|
|
193
|
+
} catch (error) {
|
|
194
|
+
return originalProject.call(this)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
restorePatches.push(() => { workspaces.project = originalProject })
|
|
198
|
+
}
|
|
199
|
+
if (workspaces !== undefined && typeof workspaces.archiveSession === 'function') {
|
|
200
|
+
const originalArchive = workspaces.archiveSession
|
|
201
|
+
workspaces.archiveSession = function patchedArchive(sessionId) {
|
|
202
|
+
const result = originalArchive.call(this, sessionId)
|
|
203
|
+
result.then(() => {
|
|
204
|
+
try {
|
|
205
|
+
if (this.sessions.list.getSnapshot().current === sessionId) this.sessions.clear()
|
|
206
|
+
} catch (e) {}
|
|
207
|
+
}, () => {})
|
|
208
|
+
return result
|
|
209
|
+
}
|
|
210
|
+
restorePatches.push(() => { workspaces.archiveSession = originalArchive })
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Official primitives replace the local icon set and status-dot
|
|
214
|
+
// replica (identical component contracts, one require).
|
|
215
|
+
const ICONS = {
|
|
216
|
+
FolderOpen: dsw.IconFolderOpen16,
|
|
217
|
+
FolderClose: dsw.IconFolderClose16,
|
|
218
|
+
Archive: dsw.IconArchiveOutline20,
|
|
219
|
+
Close: dsw.IconCloseOutline16,
|
|
220
|
+
Refresh: dsw.IconRefreshOutline16,
|
|
221
|
+
Search: dsw.IconSearchOutline16,
|
|
222
|
+
Personalization: dsw.IconPersonalizationOutline16,
|
|
223
|
+
Check: dsw.IconCheckOutline16,
|
|
224
|
+
Trash: dsw.IconTrashOutline16,
|
|
225
|
+
}
|
|
226
|
+
function Icon({ name, size, className }) {
|
|
227
|
+
const Cmp = ICONS[name]
|
|
228
|
+
if (Cmp === undefined) return null
|
|
229
|
+
return h(Cmp, { size, className })
|
|
230
|
+
}
|
|
231
|
+
const StateDot = ({ state, size }) => h(dsw.StateDot, { state, size })
|
|
232
|
+
|
|
233
|
+
// Official sessionStatuses precedence (the subagent-count dimension has no data source here yet):
|
|
234
|
+
// pending interaction (warning) > running (ongoing) > completed (done; idle shows no dot).
|
|
235
|
+
function sessionStatusOf(row) {
|
|
236
|
+
if (row.pendingInteraction !== undefined) return { state: 'warning', label: t('status.' + String(row.pendingInteraction).replace(/^plan-review$/, 'planReview').replace(/^question$/, 'waitingAnswer').replace(/^approval$/, 'waitingApproval')) }
|
|
237
|
+
if (row.running) return { state: 'ongoing', label: t('status.running') }
|
|
238
|
+
if (row.completed) return { state: 'done', label: t('status.completed') }
|
|
239
|
+
return null
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Map a host-side {ok:false, code, detail} envelope to a localized message.
|
|
243
|
+
// The host has no locale seat (cross-process), so it speaks stable codes.
|
|
244
|
+
function hostError(r) {
|
|
245
|
+
const code = r !== null && typeof r === 'object' && typeof r.code === 'string' ? r.code : 'unknown'
|
|
246
|
+
const detail = r !== null && typeof r === 'object' && r.detail !== null && typeof r.detail === 'object' ? r.detail : {}
|
|
247
|
+
const key = 'err.' + code
|
|
248
|
+
const known = t(key) !== key
|
|
249
|
+
return new Error(known ? t(key, detail) : t('err.unknown'))
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function relativeTime(updatedAt, now) {
|
|
253
|
+
const MIN = 6e4; const HOUR = 36e5; const DAY = 864e5
|
|
254
|
+
const parsed = typeof updatedAt === 'number' ? updatedAt : Date.parse(updatedAt)
|
|
255
|
+
if (!Number.isFinite(parsed)) return ''
|
|
256
|
+
const diff = Math.max(0, now - parsed)
|
|
257
|
+
if (diff < MIN) return t('time.now')
|
|
258
|
+
if (diff < HOUR) return t('time.minutes', { n: Math.floor(diff / MIN) })
|
|
259
|
+
if (diff < DAY) return t('time.hours', { n: Math.floor(diff / HOUR) })
|
|
260
|
+
if (diff < 30 * DAY) return t('time.days', { n: Math.floor(diff / DAY) })
|
|
261
|
+
if (diff < 365 * DAY) return t('time.months', { n: Math.floor(diff / (30 * DAY)) })
|
|
262
|
+
return t('time.years', { n: Math.floor(diff / (365 * DAY)) })
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Shared view state (groupBy / orderBy / manual orders) ──
|
|
266
|
+
// The native browser's view store is a root-scoped EngineStoreHandle
|
|
267
|
+
// registered with the sidebar.workspaces slot; the slots registry
|
|
268
|
+
// resolves ONE instance for it and the native component renders from
|
|
269
|
+
// that same instance. Taking it directly makes the sync exact in both
|
|
270
|
+
// directions: subscribe() covers native -> panel instantly, actions
|
|
271
|
+
// cover panel -> native (the native tree re-renders off the shared
|
|
272
|
+
// instance, and its persistence layer keeps writing localStorage).
|
|
273
|
+
const DEFAULT_VIEW = { groupBy: 'workspace', orderBy: 'updated', accounts: {} }
|
|
274
|
+
function normalizeView(snapshot) {
|
|
275
|
+
const accounts = snapshot.sessionOrderByAccount
|
|
276
|
+
return {
|
|
277
|
+
groupBy: snapshot.groupBy === 'flat' ? 'flat' : 'workspace',
|
|
278
|
+
orderBy: snapshot.orderBy === 'manual' ? 'manual' : 'updated',
|
|
279
|
+
accounts: accounts !== null && typeof accounts === 'object' && !Array.isArray(accounts) ? accounts : {},
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function nativeViewStore() {
|
|
283
|
+
try {
|
|
284
|
+
for (const entry of slots.entriesOfSlot('sidebar.workspaces')) {
|
|
285
|
+
if (entry.store === undefined) continue
|
|
286
|
+
return slots.resolveStore(entry.store)
|
|
287
|
+
}
|
|
288
|
+
} catch (e) {}
|
|
289
|
+
return null
|
|
290
|
+
}
|
|
291
|
+
// The root slot's layout store instance (AppFrame's store seat):
|
|
292
|
+
// sidebar geometry is the authoritative collapse signal (AppFrame:
|
|
293
|
+
// collapsed = narrow ? !narrowExpanded : sidebar === 0).
|
|
294
|
+
function nativeLayoutStore() {
|
|
295
|
+
try {
|
|
296
|
+
for (const entry of slots.entriesOfSlot('root')) {
|
|
297
|
+
if (entry.store === undefined) continue
|
|
298
|
+
return slots.resolveStore(entry.store)
|
|
299
|
+
}
|
|
300
|
+
} catch (e) {}
|
|
301
|
+
return null
|
|
302
|
+
}
|
|
303
|
+
function useSidebarCollapsed() {
|
|
304
|
+
const [collapsed, setCollapsed] = react.useState(false)
|
|
305
|
+
react.useEffect(() => {
|
|
306
|
+
let disposed = false
|
|
307
|
+
let unsubStore
|
|
308
|
+
// Registration order is unconstrained (the official slot registry
|
|
309
|
+
// makes no promises): if the layout store is not yet registered at
|
|
310
|
+
// first render, re-resolve when the 'root' slot's entries change.
|
|
311
|
+
const attach = () => {
|
|
312
|
+
const instance = nativeLayoutStore()
|
|
313
|
+
if (instance === null) return false
|
|
314
|
+
// Retries must not stack store subscriptions: release the
|
|
315
|
+
// previous one before taking a fresh handle.
|
|
316
|
+
if (unsubStore !== undefined) { unsubStore(); unsubStore = undefined }
|
|
317
|
+
const read = () => {
|
|
318
|
+
const s = instance.getSnapshot()
|
|
319
|
+
setCollapsed(s.narrow === true ? s.narrowExpanded !== true : s.sidebar === 0)
|
|
320
|
+
}
|
|
321
|
+
read()
|
|
322
|
+
unsubStore = instance.subscribe(read)
|
|
323
|
+
return true
|
|
324
|
+
}
|
|
325
|
+
if (attach()) return () => { disposed = true; if (unsubStore !== undefined) unsubStore() }
|
|
326
|
+
const unsubSlots = slots.subscribe('root', () => {
|
|
327
|
+
if (disposed || attach()) return
|
|
328
|
+
})
|
|
329
|
+
return () => { disposed = true; unsubSlots(); if (unsubStore !== undefined) unsubStore() }
|
|
330
|
+
}, [])
|
|
331
|
+
return collapsed
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// The native flat order account tracks only visible sessions (archived
|
|
335
|
+
// ids are reconciled away), so the archived flat order is panel-owned.
|
|
336
|
+
const FLAT_ORDER_KEY = 'dsh.archive-manager.flatOrder.v1'
|
|
337
|
+
function readFlatOrder() {
|
|
338
|
+
try {
|
|
339
|
+
const raw = window.localStorage.getItem(FLAT_ORDER_KEY)
|
|
340
|
+
const parsed = raw === null ? null : JSON.parse(raw)
|
|
341
|
+
return Array.isArray(parsed) ? parsed : []
|
|
342
|
+
} catch (e) { return [] }
|
|
343
|
+
}
|
|
344
|
+
// Orphan from an earlier build that wrote the wrong persist key.
|
|
345
|
+
try { window.localStorage.removeItem('dsh.workspace.view.v5.root') } catch (e) {}
|
|
346
|
+
|
|
347
|
+
// Official search query hygiene: strip NUL, cap at 500 code units without
|
|
348
|
+
// splitting a surrogate pair (the session.search wire contract).
|
|
349
|
+
const SEARCH_MAX = 500
|
|
350
|
+
function sanitizeSearchQuery(value) {
|
|
351
|
+
const withoutNul = value.replaceAll('\0', '')
|
|
352
|
+
if (withoutNul.length <= SEARCH_MAX) return withoutNul
|
|
353
|
+
let end = SEARCH_MAX
|
|
354
|
+
const last = withoutNul.charCodeAt(end - 1)
|
|
355
|
+
const next = withoutNul.charCodeAt(end)
|
|
356
|
+
if (last >= 0xD800 && last <= 0xDBFF && next >= 0xDC00 && next <= 0xDFFF) end--
|
|
357
|
+
return withoutNul.slice(0, end)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function makeStore(initial) {
|
|
361
|
+
let value = initial
|
|
362
|
+
const listeners = new Set()
|
|
363
|
+
return {
|
|
364
|
+
get: () => value,
|
|
365
|
+
set: (next) => { value = next; for (const fn of listeners) fn() },
|
|
366
|
+
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const panel = makeStore(false)
|
|
370
|
+
|
|
371
|
+
function useStore(store) {
|
|
372
|
+
const [value, setValue] = react.useState(store.get())
|
|
373
|
+
react.useEffect(() => store.subscribe(() => setValue(store.get())), [store])
|
|
374
|
+
return value
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Official ViewOptionsMenu replica: personalization trigger + portal menu
|
|
378
|
+
// with two labeled radio groups (group-by, order-by), dense rows, checks.
|
|
379
|
+
function ViewOptionsMenu({ groupBy, orderBy, onPick }) {
|
|
380
|
+
const [open, setOpen] = react.useState(false)
|
|
381
|
+
const [rect, setRect] = react.useState(null)
|
|
382
|
+
const anchorRef = react.useRef(null)
|
|
383
|
+
const portalRef = react.useRef(null)
|
|
384
|
+
react.useEffect(() => {
|
|
385
|
+
if (!open) return
|
|
386
|
+
const place = () => {
|
|
387
|
+
const el = anchorRef.current
|
|
388
|
+
if (el === null || el.getBoundingClientRect === undefined) return
|
|
389
|
+
const r = el.getBoundingClientRect()
|
|
390
|
+
setRect({ top: r.bottom + 4, right: window.innerWidth - r.right })
|
|
391
|
+
}
|
|
392
|
+
place()
|
|
393
|
+
const onDoc = (e) => {
|
|
394
|
+
const target = e.target
|
|
395
|
+
if (target instanceof Node
|
|
396
|
+
&& ((anchorRef.current !== null && anchorRef.current.contains(target))
|
|
397
|
+
|| (portalRef.current !== null && portalRef.current.contains(target)))) return
|
|
398
|
+
setOpen(false)
|
|
399
|
+
}
|
|
400
|
+
const onKey = (e) => { if (e.key === 'Escape') setOpen(false) }
|
|
401
|
+
document.addEventListener('pointerdown', onDoc)
|
|
402
|
+
document.addEventListener('keydown', onKey)
|
|
403
|
+
window.addEventListener('resize', place)
|
|
404
|
+
return () => {
|
|
405
|
+
document.removeEventListener('pointerdown', onDoc)
|
|
406
|
+
document.removeEventListener('keydown', onKey)
|
|
407
|
+
window.removeEventListener('resize', place)
|
|
408
|
+
}
|
|
409
|
+
}, [open])
|
|
410
|
+
const selected = new Set([groupBy, orderBy])
|
|
411
|
+
const items = [
|
|
412
|
+
{ type: 'label', id: 'groupBy.label' },
|
|
413
|
+
{ id: 'workspace', label: t('groupBy.workspace') },
|
|
414
|
+
{ id: 'flat', label: t('groupBy.flat') },
|
|
415
|
+
{ type: 'separator' },
|
|
416
|
+
{ type: 'label', id: 'orderBy.label' },
|
|
417
|
+
{ id: 'manual', label: t('orderBy.manual') },
|
|
418
|
+
{ id: 'updated', label: t('orderBy.updated') },
|
|
419
|
+
]
|
|
420
|
+
return h(react.Fragment, null,
|
|
421
|
+
h('button', {
|
|
422
|
+
type: 'button', ref: anchorRef,
|
|
423
|
+
className: 'arcv-iconButton',
|
|
424
|
+
'aria-label': t('viewOptions.label'), title: t('viewOptions.label'),
|
|
425
|
+
'aria-expanded': open,
|
|
426
|
+
onClick: (e) => { e.stopPropagation(); setOpen(!open) },
|
|
427
|
+
}, h(Icon, { name: 'Personalization', size: 16 })),
|
|
428
|
+
open && rect !== null
|
|
429
|
+
? h('div', {
|
|
430
|
+
ref: portalRef,
|
|
431
|
+
className: 'arcv-menuPortal', role: 'menu',
|
|
432
|
+
style: { top: rect.top + 'px', right: rect.right + 'px' },
|
|
433
|
+
},
|
|
434
|
+
h('div', { className: 'arcv-menuCard' },
|
|
435
|
+
...items.map((item) => item.type === 'label'
|
|
436
|
+
? h('div', { key: item.id, className: 'arcv-menuLabel arcv-menuLabelDense' }, t(item.id))
|
|
437
|
+
: item.type === 'separator'
|
|
438
|
+
? h('div', { key: 'sep', className: 'arcv-menuSeparator' })
|
|
439
|
+
: h('button', {
|
|
440
|
+
type: 'button', key: item.id, role: 'menuitemradio',
|
|
441
|
+
'aria-checked': selected.has(item.id),
|
|
442
|
+
className: 'arcv-menuItem',
|
|
443
|
+
onClick: (e) => { e.stopPropagation(); onPick(item.id); setOpen(false) },
|
|
444
|
+
},
|
|
445
|
+
h('span', { className: 'arcv-menuItemIcon' }),
|
|
446
|
+
h('span', { className: 'arcv-menuItemLabel' }, item.label),
|
|
447
|
+
selected.has(item.id) ? h('span', { className: 'arcv-menuCheck' }, h(Icon, { name: 'Check', size: 14 })) : null))))
|
|
448
|
+
: null)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function ArchivePanel(props) {
|
|
452
|
+
const open = useStore(panel)
|
|
453
|
+
const archivedIds = props.useWorkspaces((s) => s.archivedSessionIds)
|
|
454
|
+
const items = props.useWorkspaces((s) => s.items)
|
|
455
|
+
const byId = props.useSessions((s) => s.byId)
|
|
456
|
+
const current = props.useSessions((s) => s.current)
|
|
457
|
+
const [expandedSet, setExpandedSet] = react.useState({})
|
|
458
|
+
const [view, setView] = react.useState(DEFAULT_VIEW)
|
|
459
|
+
const viewStoreRef = react.useRef(null)
|
|
460
|
+
const [flatOrder, setFlatOrder] = react.useState(readFlatOrder)
|
|
461
|
+
const [query, setQuery] = react.useState('')
|
|
462
|
+
const [searchExpanded, setSearchExpanded] = react.useState(false)
|
|
463
|
+
const [searchStatus, setSearchStatus] = react.useState('idle')
|
|
464
|
+
const [searchItems, setSearchItems] = react.useState([])
|
|
465
|
+
const searchRootRef = react.useRef(null)
|
|
466
|
+
const searchInputRef = react.useRef(null)
|
|
467
|
+
// Native -> panel: subscribe to the shared store instance — the
|
|
468
|
+
// same one the native browser renders from. localStorage bridging
|
|
469
|
+
// cannot work here: the engine rehydrates only at instance creation
|
|
470
|
+
// and offers no cross-instance notification.
|
|
471
|
+
react.useEffect(() => {
|
|
472
|
+
let disposed = false
|
|
473
|
+
let unsubStore
|
|
474
|
+
// Late registration retry (see useSidebarCollapsed): without it a
|
|
475
|
+
// store registered after first render would leave the panel's view
|
|
476
|
+
// options permanently on local state, silently breaking the sync.
|
|
477
|
+
const attach = () => {
|
|
478
|
+
const instance = nativeViewStore()
|
|
479
|
+
viewStoreRef.current = instance
|
|
480
|
+
if (instance === null) return false
|
|
481
|
+
// Retries must not stack store subscriptions (see the layout
|
|
482
|
+
// twin above): release the previous one first.
|
|
483
|
+
if (unsubStore !== undefined) { unsubStore(); unsubStore = undefined }
|
|
484
|
+
const read = () => setView(normalizeView(instance.getSnapshot()))
|
|
485
|
+
read()
|
|
486
|
+
unsubStore = instance.subscribe(read)
|
|
487
|
+
return true
|
|
488
|
+
}
|
|
489
|
+
if (attach()) return () => { disposed = true; if (unsubStore !== undefined) unsubStore() }
|
|
490
|
+
const unsubSlots = slots.subscribe('sidebar.workspaces', () => {
|
|
491
|
+
if (disposed || attach()) return
|
|
492
|
+
})
|
|
493
|
+
return () => { disposed = true; unsubSlots(); if (unsubStore !== undefined) unsubStore() }
|
|
494
|
+
}, [])
|
|
495
|
+
// Collapse search on outside click only when the query is empty:
|
|
496
|
+
// an active search keeps the box open — clicking rows or elsewhere
|
|
497
|
+
// must not discard a typed query; only close/clear/esc collapses it.
|
|
498
|
+
react.useEffect(() => {
|
|
499
|
+
if (!searchExpanded || query !== '') return
|
|
500
|
+
const onDoc = (e) => {
|
|
501
|
+
const target = e.target
|
|
502
|
+
if (target instanceof Node && searchRootRef.current !== null && searchRootRef.current.contains(target)) return
|
|
503
|
+
setSearchExpanded(false)
|
|
504
|
+
}
|
|
505
|
+
document.addEventListener('pointerdown', onDoc)
|
|
506
|
+
return () => document.removeEventListener('pointerdown', onDoc)
|
|
507
|
+
}, [searchExpanded, query])
|
|
508
|
+
// Debounced content search (native pattern: abort superseded).
|
|
509
|
+
// 200ms (native uses 250): the host side is an indexed FTS5 query
|
|
510
|
+
// (~tens of ms), so the debounce dominated the perceived latency.
|
|
511
|
+
// A bounded LRU cache makes retried/backspaced queries instant.
|
|
512
|
+
const searchCacheRef = react.useRef(new Map())
|
|
513
|
+
const normalizedQuery = sanitizeSearchQuery(query).trim()
|
|
514
|
+
react.useEffect(() => {
|
|
515
|
+
if (normalizedQuery === '') { setSearchStatus('idle'); setSearchItems([]); return }
|
|
516
|
+
const cache = searchCacheRef.current
|
|
517
|
+
const cached = cache.get(normalizedQuery)
|
|
518
|
+
if (cached !== undefined) {
|
|
519
|
+
cache.delete(normalizedQuery) // LRU touch: re-insert moves the key to the eviction tail
|
|
520
|
+
cache.set(normalizedQuery, cached)
|
|
521
|
+
setSearchItems(cached); setSearchStatus('ready'); return
|
|
522
|
+
}
|
|
523
|
+
const controller = new AbortController()
|
|
524
|
+
setSearchStatus('loading')
|
|
525
|
+
const timer = window.setTimeout(() => {
|
|
526
|
+
if (sessions === undefined || typeof sessions.search !== 'function') { setSearchStatus('name-only'); return }
|
|
527
|
+
sessions.search(normalizedQuery, controller.signal).then((result) => {
|
|
528
|
+
if (controller.signal.aborted) return
|
|
529
|
+
if (result.ok !== true) { setSearchStatus('name-only'); return }
|
|
530
|
+
cache.set(normalizedQuery, result.value.items)
|
|
531
|
+
if (cache.size > 16) cache.delete(cache.keys().next().value)
|
|
532
|
+
setSearchItems(result.value.items)
|
|
533
|
+
setSearchStatus('ready')
|
|
534
|
+
}).catch(() => { if (!controller.signal.aborted) setSearchStatus('name-only') })
|
|
535
|
+
}, 200)
|
|
536
|
+
return () => { window.clearTimeout(timer); controller.abort() }
|
|
537
|
+
}, [normalizedQuery])
|
|
538
|
+
const [confirmTarget, setConfirmTarget] = react.useState(null)
|
|
539
|
+
const [busy, setBusy] = react.useState(false)
|
|
540
|
+
const [error, setError] = react.useState(null)
|
|
541
|
+
const [hint, setHint] = react.useState(null)
|
|
542
|
+
const [confirmError, setConfirmError] = react.useState(null)
|
|
543
|
+
const busyRef = react.useRef(false)
|
|
544
|
+
busyRef.current = busy
|
|
545
|
+
const confirmDeleteRef = react.useRef(null)
|
|
546
|
+
const confirmBtnRef = react.useRef(null)
|
|
547
|
+
const modalRef = react.useRef(null)
|
|
548
|
+
// Focus lifecycle for the destructive-confirm modal (client review
|
|
549
|
+
// M1/M2): focus moves to the confirm button on open, is trapped
|
|
550
|
+
// inside the dialog while open, and is restored to the trigger on
|
|
551
|
+
// close. The global Enter handler ignores events aimed at embedded
|
|
552
|
+
// controls (cancel button, inputs) and swallows their default
|
|
553
|
+
// activation, so Enter on "Cancel" can never confirm the delete.
|
|
554
|
+
const restoreFocusRef = react.useRef(null)
|
|
555
|
+
react.useEffect(() => {
|
|
556
|
+
if (confirmTarget === null) return
|
|
557
|
+
if (modalRef.current !== null && !modalRef.current.contains(document.activeElement)) {
|
|
558
|
+
restoreFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
|
559
|
+
if (confirmBtnRef.current !== null) confirmBtnRef.current.focus()
|
|
560
|
+
}
|
|
561
|
+
const onKey = (e) => {
|
|
562
|
+
// A delete in flight cannot be dismissed mid-air (native modal
|
|
563
|
+
// discipline): Esc while busy would swallow the failure report.
|
|
564
|
+
if (e.key === 'Escape') { if (!busyRef.current) setConfirmTarget(null); return }
|
|
565
|
+
if (e.key === 'Enter') {
|
|
566
|
+
const target = e.target
|
|
567
|
+
const fromConfirm = target instanceof Node && confirmBtnRef.current !== null && confirmBtnRef.current.contains(target)
|
|
568
|
+
const fromDialog = target === modalRef.current
|
|
569
|
+
if (!fromConfirm && !fromDialog) return // inputs/cancel keep their own key semantics
|
|
570
|
+
e.preventDefault() // no double-fire with native button activation
|
|
571
|
+
if (!busyRef.current && confirmDeleteRef.current !== null) confirmDeleteRef.current()
|
|
572
|
+
}
|
|
573
|
+
if (e.key === 'Tab') {
|
|
574
|
+
const dialog = modalRef.current
|
|
575
|
+
if (dialog === null) return
|
|
576
|
+
const focusables = dialog.querySelectorAll('button:not(:disabled)')
|
|
577
|
+
// Busy window: every button is disabled, so the browser has
|
|
578
|
+
// blurred to <body> and an empty focusables list must still
|
|
579
|
+
// trap the Tab press — otherwise focus escapes to background
|
|
580
|
+
// controls while the modal is open.
|
|
581
|
+
if (focusables.length === 0) { e.preventDefault(); return }
|
|
582
|
+
const first = focusables[0]
|
|
583
|
+
const last = focusables[focusables.length - 1]
|
|
584
|
+
const active = document.activeElement
|
|
585
|
+
if (e.shiftKey) {
|
|
586
|
+
if (active === first || !dialog.contains(active)) { e.preventDefault(); last.focus() }
|
|
587
|
+
} else if (active === last || !dialog.contains(active)) { e.preventDefault(); first.focus() }
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
document.addEventListener('keydown', onKey)
|
|
591
|
+
return () => {
|
|
592
|
+
document.removeEventListener('keydown', onKey)
|
|
593
|
+
const restore = restoreFocusRef.current
|
|
594
|
+
restoreFocusRef.current = null
|
|
595
|
+
if (restore !== null && restore.isConnected) try { restore.focus() } catch (e2) {}
|
|
596
|
+
}
|
|
597
|
+
}, [confirmTarget === null])
|
|
598
|
+
// Entering/closing the panel resets its search (a reopened panel
|
|
599
|
+
// starts fresh, matching the native browser's initial state).
|
|
600
|
+
react.useEffect(() => {
|
|
601
|
+
if (open) return
|
|
602
|
+
setQuery('')
|
|
603
|
+
setSearchExpanded(false)
|
|
604
|
+
}, [open])
|
|
605
|
+
// Manual-order drag state (hooks must sit above the open guard).
|
|
606
|
+
const [drag, setDrag] = react.useState(null)
|
|
607
|
+
const dragCommittedRef = react.useRef(false)
|
|
608
|
+
// Dropped-row settle animation: a committed drag remounts the moved
|
|
609
|
+
// row once (key suffix) so the standard row mount fade replays at
|
|
610
|
+
// its new position — the native sidebar's drop-settle visual. The
|
|
611
|
+
// seq alternator keeps rapid consecutive drags of the same row
|
|
612
|
+
// remounting; the key STAYS suffixed after the timer (clearing it
|
|
613
|
+
// would remount a second time and replay the fade). Only the map
|
|
614
|
+
// entry is retired; the suffix persists until the same row is
|
|
615
|
+
// dragged again. prefers-reduced-motion disables the animation.
|
|
616
|
+
const [settledRow, setSettledRow] = react.useState(null)
|
|
617
|
+
const settleTimerRef = react.useRef(undefined)
|
|
618
|
+
react.useEffect(() => () => {
|
|
619
|
+
if (settleTimerRef.current !== undefined) clearTimeout(settleTimerRef.current)
|
|
620
|
+
}, [])
|
|
621
|
+
const markSettled = (id) => {
|
|
622
|
+
if (settleTimerRef.current !== undefined) clearTimeout(settleTimerRef.current)
|
|
623
|
+
setSettledRow((prev) => ({ id, seq: (prev !== null && prev.id === id ? prev.seq : 0) + 1 }))
|
|
624
|
+
// Retire the tracking entry after the settle window so a NEW drag
|
|
625
|
+
// of a different row replaces state cleanly; the rendered key for
|
|
626
|
+
// the settled row keeps its suffix (see renderRow).
|
|
627
|
+
settleTimerRef.current = setTimeout(() => {
|
|
628
|
+
settleTimerRef.current = undefined
|
|
629
|
+
}, 250)
|
|
630
|
+
}
|
|
631
|
+
// Computed BEFORE the effects that reference it — a `const` in the
|
|
632
|
+
// dependency array of an earlier hook is a TDZ crash at mount.
|
|
633
|
+
const searching = normalizedQuery !== ''
|
|
634
|
+
// Drop any in-flight drag when the panel closes or a search
|
|
635
|
+
// replaces the rows: both unmount the source row, whose detached
|
|
636
|
+
// dragend never reaches the React root — without this reset the
|
|
637
|
+
// document-level drag acceptance and the insertion marker would
|
|
638
|
+
// linger until the next drag.
|
|
639
|
+
react.useEffect(() => {
|
|
640
|
+
if (open && !searching) return
|
|
641
|
+
setDrag(null)
|
|
642
|
+
}, [open, searching])
|
|
643
|
+
// Document-level drag acceptance while a row drag is active
|
|
644
|
+
// (official useNativeDragAcceptance replica): row hover still owns
|
|
645
|
+
// the insertion marker, and releasing outside the list must not be
|
|
646
|
+
// rendered as a rejected drop (or, on Firefox, navigate the
|
|
647
|
+
// text/plain payload) before dragend commits the last marker.
|
|
648
|
+
react.useEffect(() => {
|
|
649
|
+
if (drag === null) return
|
|
650
|
+
const acceptDrag = (e) => {
|
|
651
|
+
e.preventDefault()
|
|
652
|
+
if (e.dataTransfer !== null) e.dataTransfer.dropEffect = 'move'
|
|
653
|
+
}
|
|
654
|
+
const acceptDrop = (e) => { e.preventDefault() }
|
|
655
|
+
document.addEventListener('dragover', acceptDrag)
|
|
656
|
+
document.addEventListener('drop', acceptDrop)
|
|
657
|
+
return () => {
|
|
658
|
+
document.removeEventListener('dragover', acceptDrag)
|
|
659
|
+
document.removeEventListener('drop', acceptDrop)
|
|
660
|
+
}
|
|
661
|
+
}, [drag === null])
|
|
662
|
+
if (!open) return null
|
|
663
|
+
const now = Date.now()
|
|
664
|
+
// Search mode: archived rows matching name/workspace plus host content
|
|
665
|
+
// results intersected with the archive set (native merges local-first,
|
|
666
|
+
// newest order; content-only rows keep backend order). (`searching`
|
|
667
|
+
// itself is computed above, before the hooks that need it.)
|
|
668
|
+
const q = normalizedQuery.toLowerCase()
|
|
669
|
+
// Deduplicate at the data inlet: a corrupted archivedSessionIds
|
|
670
|
+
// (external tampering) must not yield duplicate rows anywhere
|
|
671
|
+
// downstream (both the search branch and the grouping branch).
|
|
672
|
+
const allRows = [...new Set(archivedIds)].map((id) => byId[id]).filter((s) => s !== undefined && s.origin !== 'subagent').map((s) => ({
|
|
673
|
+
id: s.id, displayTitle: s.displayTitle, blank: s.blank, updatedAt: s.updatedAt,
|
|
674
|
+
running: s.running === true, completed: s.completed === true, pendingInteraction: s.pendingInteraction,
|
|
675
|
+
}))
|
|
676
|
+
const workspaceTitleOf = (id) => {
|
|
677
|
+
const owner = items.find((w) => w.sessionIds.includes(id))
|
|
678
|
+
return owner !== undefined ? owner.title : t('ungrouped')
|
|
679
|
+
}
|
|
680
|
+
const byRecency = (a, b) => (b.updatedAt !== a.updatedAt ? b.updatedAt - a.updatedAt : (a.id < b.id ? -1 : 1))
|
|
681
|
+
let rows
|
|
682
|
+
if (searching) {
|
|
683
|
+
const contentIds = new Set(searchStatus === 'ready' ? searchItems.map((item) => item.sessionId) : [])
|
|
684
|
+
const localMatches = allRows.filter((row) =>
|
|
685
|
+
String(row.displayTitle).toLowerCase().includes(q) || workspaceTitleOf(row.id).toLowerCase().includes(q))
|
|
686
|
+
if (searchStatus === 'loading') {
|
|
687
|
+
rows = localMatches
|
|
688
|
+
} else {
|
|
689
|
+
const seen = new Set(localMatches.map((r) => r.id))
|
|
690
|
+
const contentSnippets = new Map(searchStatus === 'ready' ? searchItems.map((item) => [item.sessionId, item.snippet]) : [])
|
|
691
|
+
rows = [
|
|
692
|
+
...localMatches.sort(byRecency).map((r) => ({ ...r, snippet: contentSnippets.get(r.id) })),
|
|
693
|
+
...allRows.filter((r) => contentIds.has(r.id) && !seen.has(r.id)).map((r) => ({ ...r, snippet: contentSnippets.get(r.id) })),
|
|
694
|
+
]
|
|
695
|
+
}
|
|
696
|
+
} else {
|
|
697
|
+
rows = allRows
|
|
698
|
+
}
|
|
699
|
+
// View-order application (native semantics):
|
|
700
|
+
// - workspace groups + manual: rows follow the workspace account
|
|
701
|
+
// order (workspace.sessionIds) — archived rows keep the relative
|
|
702
|
+
// position they had as members, exactly like the native archived
|
|
703
|
+
// view; updated sorts by newest activity.
|
|
704
|
+
// - flat + manual: the panel-owned archived flat order with recency
|
|
705
|
+
// for untracked rows; updated = newest-first.
|
|
706
|
+
const storedAccounts = view.accounts
|
|
707
|
+
// Order follows the STORED sequence: rows the account knows come
|
|
708
|
+
// out in the dragged order (not the previous render order); rows it
|
|
709
|
+
// has never seen append after.
|
|
710
|
+
const orderFrom = (stored, memberIds) => {
|
|
711
|
+
const memberSet = new Set(memberIds)
|
|
712
|
+
// Dedupe first: an externally corrupted account holding a
|
|
713
|
+
// repeated id must not yield a repeated row (undefined holes
|
|
714
|
+
// crash renderRow) nor push a `null` back into the store.
|
|
715
|
+
const known = [...new Set(stored)].filter((id) => memberSet.has(id))
|
|
716
|
+
const knownSet = new Set(known)
|
|
717
|
+
return [...known, ...memberIds.filter((id) => !knownSet.has(id))]
|
|
718
|
+
}
|
|
719
|
+
const accountOrder = (key, memberIds) => orderFrom(Array.isArray(storedAccounts[key]) ? storedAccounts[key] : [], memberIds)
|
|
720
|
+
// Workspace manual order: a drag-written account covering every
|
|
721
|
+
// member wins wholesale; otherwise the workspace ledger orders rows
|
|
722
|
+
// (archived rows keep their member positions) with unknown rows last.
|
|
723
|
+
const workspaceOrder = (workspaceId, memberIds) => {
|
|
724
|
+
const stored = Array.isArray(storedAccounts[workspaceId]) ? storedAccounts[workspaceId] : []
|
|
725
|
+
const memberSet = new Set(memberIds)
|
|
726
|
+
let knownCount = 0
|
|
727
|
+
for (const id of stored) if (memberSet.has(id)) knownCount++
|
|
728
|
+
if (knownCount === memberIds.length) return accountOrder(workspaceId, memberIds)
|
|
729
|
+
const owner = items.find((w) => w.workspaceId === workspaceId)
|
|
730
|
+
if (owner === undefined) return memberIds
|
|
731
|
+
const ledger = owner.sessionIds
|
|
732
|
+
const ordered = ledger.filter((id) => memberSet.has(id))
|
|
733
|
+
const orderedSet = new Set(ordered)
|
|
734
|
+
return [...ordered, ...memberIds.filter((id) => !orderedSet.has(id))]
|
|
735
|
+
}
|
|
736
|
+
if (view.groupBy === 'flat') {
|
|
737
|
+
if (view.orderBy === 'manual') {
|
|
738
|
+
const order = orderFrom(flatOrder, rows.map((r) => r.id))
|
|
739
|
+
const byIdRow = new Map(rows.map((r) => [r.id, r]))
|
|
740
|
+
rows = order.map((id) => byIdRow.get(id)).filter((r) => r !== undefined)
|
|
741
|
+
} else {
|
|
742
|
+
rows = [...rows].sort(byRecency)
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
const groups = []
|
|
746
|
+
const byWorkspace = new Map()
|
|
747
|
+
const ungrouped = []
|
|
748
|
+
const flatView = view.groupBy === 'flat' || searching
|
|
749
|
+
if (!flatView) {
|
|
750
|
+
for (const row of rows) {
|
|
751
|
+
const owner = items.find((w) => w.sessionIds.includes(row.id))
|
|
752
|
+
if (owner === undefined) ungrouped.push(row)
|
|
753
|
+
else {
|
|
754
|
+
let entry = byWorkspace.get(owner.workspaceId)
|
|
755
|
+
if (entry === undefined) { entry = { key: 'w:' + owner.workspaceId, title: owner.title, rows: [], workspaceId: owner.workspaceId }; byWorkspace.set(owner.workspaceId, entry) }
|
|
756
|
+
entry.rows.push(row)
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
for (const entry of byWorkspace.values()) {
|
|
760
|
+
if (view.orderBy === 'updated') {
|
|
761
|
+
entry.rows.sort(byRecency)
|
|
762
|
+
} else {
|
|
763
|
+
const order = workspaceOrder(entry.workspaceId, entry.rows.map((r) => r.id))
|
|
764
|
+
const map = new Map(entry.rows.map((r) => [r.id, r]))
|
|
765
|
+
entry.rows = order.map((id) => map.get(id)).filter((r) => r !== undefined)
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
// Ungrouped rows follow the shared "" account (native reconcile
|
|
769
|
+
// keys that account on ALL unaccounted ids, archived included,
|
|
770
|
+
// so the order genuinely round-trips through the native store).
|
|
771
|
+
if (view.orderBy === 'updated') ungrouped.sort(byRecency)
|
|
772
|
+
else {
|
|
773
|
+
const order = accountOrder('', ungrouped.map((r) => r.id))
|
|
774
|
+
const map = new Map(ungrouped.map((r) => [r.id, r]))
|
|
775
|
+
const ordered = order.map((id) => map.get(id)).filter((r) => r !== undefined)
|
|
776
|
+
ungrouped.length = 0
|
|
777
|
+
ungrouped.push(...ordered)
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
if (flatView) {
|
|
781
|
+
groups.push({ key: 'flat', title: t('section.sessions'), rows, flat: true })
|
|
782
|
+
} else {
|
|
783
|
+
for (const entry of byWorkspace.values()) groups.push(entry)
|
|
784
|
+
if (ungrouped.length > 0) groups.push({ key: 'ungrouped', title: t('ungrouped'), rows: ungrouped })
|
|
785
|
+
}
|
|
786
|
+
const fail = (reason) => setError(reason instanceof Error ? reason.message : String(reason))
|
|
787
|
+
const run = (action) => { setBusy(true); setError(null); return Promise.resolve().then(action).catch(fail).finally(() => setBusy(false)) }
|
|
788
|
+
const localRemove = (id) => {
|
|
789
|
+
if (workspaces !== undefined) {
|
|
790
|
+
// Optimistic echo saving one frame before the host's broadcast
|
|
791
|
+
// frame lands (the domain write always follows). Talks to two
|
|
792
|
+
// internal manager fields — guarded so an upstream rename merely
|
|
793
|
+
// drops the optimization, never throws.
|
|
794
|
+
try {
|
|
795
|
+
const m = workspaces.manager
|
|
796
|
+
if (m !== undefined && Array.isArray(m.archivedSessionIds)) {
|
|
797
|
+
m.archivedSessionIds = m.archivedSessionIds.filter((x) => x !== id)
|
|
798
|
+
if (m.notifier !== undefined && typeof m.notifier.markDirty === 'function') m.notifier.markDirty()
|
|
799
|
+
}
|
|
800
|
+
} catch (e) {}
|
|
801
|
+
if (typeof workspaces.project === 'function') workspaces.project()
|
|
802
|
+
if (typeof workspaces.refresh === 'function') workspaces.refresh()
|
|
803
|
+
}
|
|
804
|
+
if (sessions !== undefined && typeof sessions.refresh === 'function') sessions.refresh()
|
|
805
|
+
}
|
|
806
|
+
// 点行只给提示:归档会话要先恢复才能看(不预览、不改状态)。
|
|
807
|
+
const onOpen = () => { setError(null); setHint('先恢复再预览') }
|
|
808
|
+
const onRestore = (id) => {
|
|
809
|
+
run(() => hostCall('unarchive', { sessionId: id }).then((r) => { if (r !== null && typeof r === 'object' && r.ok !== true) throw hostError(r) }).then(() => localRemove(id)))
|
|
810
|
+
}
|
|
811
|
+
const onConfirmDelete = () => {
|
|
812
|
+
if (confirmTarget === null || busyRef.current) return
|
|
813
|
+
const id = confirmTarget.id
|
|
814
|
+
// Page-open protection (host no longer guards this): never delete
|
|
815
|
+
// the session currently shown in the live view. Archived sessions
|
|
816
|
+
// are usually not current; this covers the archive-then-clear race.
|
|
817
|
+
if (current === id) {
|
|
818
|
+
setConfirmError(t('err.still-live'))
|
|
819
|
+
return
|
|
820
|
+
}
|
|
821
|
+
busyRef.current = true
|
|
822
|
+
const work = () => hostCall('delete', { sessionId: id }).then((r) => { if (r !== null && typeof r === 'object' && r.ok !== true) throw hostError(r) })
|
|
823
|
+
setBusy(true)
|
|
824
|
+
setError(null)
|
|
825
|
+
setConfirmError(null)
|
|
826
|
+
Promise.resolve().then(work)
|
|
827
|
+
// Local row removal only after the host confirmed: a failed
|
|
828
|
+
// delete must not flicker the row away (client review m1). The
|
|
829
|
+
// optional private-API echo is best-effort — a throw here must
|
|
830
|
+
// not misreport a completed delete as failed.
|
|
831
|
+
.then(() => {
|
|
832
|
+
const m = sessions !== undefined ? sessions.manager : undefined
|
|
833
|
+
if (m !== undefined && typeof m.recordMutation === 'function') {
|
|
834
|
+
try { m.recordMutation({ kind: 'remove', sessionId: id }) } catch (e) {}
|
|
835
|
+
}
|
|
836
|
+
return localRemove(id)
|
|
837
|
+
})
|
|
838
|
+
.then(() => { setConfirmTarget(null) })
|
|
839
|
+
.catch((reason) => {
|
|
840
|
+
if (sessions !== undefined && typeof sessions.refresh === 'function') sessions.refresh()
|
|
841
|
+
setConfirmError(reason instanceof Error ? reason.message : String(reason))
|
|
842
|
+
// Failure path: focus was lost to <body> when busy disabled
|
|
843
|
+
// the confirm button — put the keyboard user back on it.
|
|
844
|
+
if (confirmBtnRef.current !== null) { try { confirmBtnRef.current.focus() } catch (e) {} }
|
|
845
|
+
})
|
|
846
|
+
.finally(() => { setBusy(false); busyRef.current = false })
|
|
847
|
+
}
|
|
848
|
+
confirmDeleteRef.current = onConfirmDelete
|
|
849
|
+
// ── Manual-order drag commit (native replica): the marker is the
|
|
850
|
+
// dashed insertion rule on the hovered row's half; commit writes the
|
|
851
|
+
// shared store account (workspace groups) or the panel-owned flat
|
|
852
|
+
// order, so manual order stays in sync both ways. Hook state lives
|
|
853
|
+
// above the open guard; only the handlers sit here.
|
|
854
|
+
const rowHalf = (e) => {
|
|
855
|
+
const rect = e.currentTarget.getBoundingClientRect()
|
|
856
|
+
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
|
|
857
|
+
}
|
|
858
|
+
const persistOrder = (accountKey, order) => {
|
|
859
|
+
const instance = viewStoreRef.current
|
|
860
|
+
if (instance !== null && typeof instance.actions.setSessionOrder === 'function') instance.actions.setSessionOrder(accountKey, order)
|
|
861
|
+
else setView((v) => ({ ...v, accounts: { ...v.accounts, [accountKey]: order } }))
|
|
862
|
+
}
|
|
863
|
+
const persistFlatOrder = (order) => {
|
|
864
|
+
try { window.localStorage.setItem(FLAT_ORDER_KEY, JSON.stringify(order)) } catch (e) {}
|
|
865
|
+
setFlatOrder(order)
|
|
866
|
+
}
|
|
867
|
+
const commitDrag = (active, over) => {
|
|
868
|
+
if (dragCommittedRef.current) return
|
|
869
|
+
dragCommittedRef.current = true
|
|
870
|
+
setDrag(null)
|
|
871
|
+
const group = groups.find((g) => g.key === active.accountKey)
|
|
872
|
+
if (group === undefined) return
|
|
873
|
+
const targetIndex = group.rows.findIndex((r) => r.id === over.id)
|
|
874
|
+
if (targetIndex === -1) return
|
|
875
|
+
const anchor = over.half === 'before' ? over.id : (group.rows[targetIndex + 1] !== undefined ? group.rows[targetIndex + 1].id : undefined)
|
|
876
|
+
if (anchor === active.sessionId) return
|
|
877
|
+
const sourceIndex = group.rows.findIndex((r) => r.id === active.sessionId)
|
|
878
|
+
const anchorIndex = anchor === undefined ? group.rows.length : group.rows.findIndex((r) => r.id === anchor)
|
|
879
|
+
if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
|
|
880
|
+
const memberIds = group.rows.map((r) => r.id)
|
|
881
|
+
const nextOrder = memberIds.filter((id) => id !== active.sessionId)
|
|
882
|
+
const insertAt = anchor === undefined ? nextOrder.length : nextOrder.indexOf(anchor)
|
|
883
|
+
nextOrder.splice(insertAt === -1 ? nextOrder.length : insertAt, 0, active.sessionId)
|
|
884
|
+
if (group.flat === true) { persistFlatOrder(nextOrder); markSettled(active.sessionId); return }
|
|
885
|
+
// Workspace/ungrouped accounts are shared with the native browser
|
|
886
|
+
// and hold the FULL membership (active + archived; the ungrouped
|
|
887
|
+
// account key is "" — writing `undefined` would poison the store):
|
|
888
|
+
// reorder only among the archived members and keep every active
|
|
889
|
+
// member's absolute slot, so a panel drag never scrambles the
|
|
890
|
+
// native manual order.
|
|
891
|
+
const accountKey = group.key === 'ungrouped' ? '' : group.workspaceId
|
|
892
|
+
if (accountKey === undefined) return
|
|
893
|
+
// Dedupe at the point the skeleton takes its final shape (both
|
|
894
|
+
// the stored branch and the ledger/member fallbacks): a repeated
|
|
895
|
+
// id would advance the cursor past nextOrder's end and persist a
|
|
896
|
+
// `null` into the shared store.
|
|
897
|
+
let skeleton
|
|
898
|
+
if (Array.isArray(storedAccounts[accountKey]) && storedAccounts[accountKey].length > 0) {
|
|
899
|
+
skeleton = storedAccounts[accountKey]
|
|
900
|
+
} else if (accountKey === '') {
|
|
901
|
+
const accounted = new Set()
|
|
902
|
+
for (const w of items) for (const sid of w.sessionIds) accounted.add(sid)
|
|
903
|
+
skeleton = Object.keys(byId).filter((id) => !accounted.has(id))
|
|
904
|
+
} else {
|
|
905
|
+
const owner = items.find((w) => w.workspaceId === accountKey)
|
|
906
|
+
skeleton = owner !== undefined ? owner.sessionIds : memberIds
|
|
907
|
+
}
|
|
908
|
+
skeleton = [...new Set(skeleton)]
|
|
909
|
+
const nextSet = new Set(nextOrder)
|
|
910
|
+
let cursor = 0
|
|
911
|
+
const merged = []
|
|
912
|
+
for (const id of skeleton) {
|
|
913
|
+
if (nextSet.has(id)) merged.push(nextOrder[cursor++])
|
|
914
|
+
else merged.push(id)
|
|
915
|
+
}
|
|
916
|
+
while (cursor < nextOrder.length) merged.push(nextOrder[cursor++])
|
|
917
|
+
persistOrder(accountKey, merged)
|
|
918
|
+
markSettled(active.sessionId)
|
|
919
|
+
}
|
|
920
|
+
const renderRow = (row, flat, group) => {
|
|
921
|
+
const status = row.blank ? null : sessionStatusOf(row)
|
|
922
|
+
const draggable = !searching && group !== undefined
|
|
923
|
+
const sameGroupDrag = drag !== null && group !== undefined && drag.accountKey === group.key
|
|
924
|
+
const marker = sameGroupDrag && drag.over !== null && drag.over.id === row.id ? drag.over.half : null
|
|
925
|
+
// One-shot remount key for the just-dropped row: replays the
|
|
926
|
+
// standard mount fade as the drop-settle animation.
|
|
927
|
+
const rowKey = settledRow !== null && settledRow.id === row.id ? row.id + '#settle' + settledRow.seq : row.id
|
|
928
|
+
return h('div', {
|
|
929
|
+
className: 'arcv-sessionRow'
|
|
930
|
+
+ (row.id === current ? ' arcv-selected' : '')
|
|
931
|
+
+ (flat && status === null ? ' arcv-flatNoStatus' : '')
|
|
932
|
+
+ (marker === 'before' ? ' arcv-dropBefore' : '')
|
|
933
|
+
+ (marker === 'after' ? ' arcv-dropAfter' : ''),
|
|
934
|
+
key: rowKey,
|
|
935
|
+
onClick: () => onOpen(row.id),
|
|
936
|
+
...(draggable ? {
|
|
937
|
+
draggable: true,
|
|
938
|
+
onDragStart: (e) => { dragCommittedRef.current = false; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', row.id); setDrag({ accountKey: group.key, sessionId: row.id, over: null }) },
|
|
939
|
+
onDragEnd: () => {
|
|
940
|
+
if (drag !== null && drag.over !== null) commitDrag(drag, drag.over)
|
|
941
|
+
else setDrag(null)
|
|
942
|
+
dragCommittedRef.current = false
|
|
943
|
+
},
|
|
944
|
+
onDragOver: (e) => {
|
|
945
|
+
if (!sameGroupDrag) return
|
|
946
|
+
e.preventDefault()
|
|
947
|
+
e.dataTransfer.dropEffect = 'move'
|
|
948
|
+
const half = rowHalf(e)
|
|
949
|
+
setDrag((d) => (d === null || (d.over !== null && d.over.id === row.id && d.over.half === half)) ? d : { ...d, over: { id: row.id, half } })
|
|
950
|
+
},
|
|
951
|
+
onDrop: (e) => {
|
|
952
|
+
if (!sameGroupDrag) return
|
|
953
|
+
e.preventDefault()
|
|
954
|
+
if (drag !== null) commitDrag(drag, { id: row.id, half: rowHalf(e) })
|
|
955
|
+
},
|
|
956
|
+
} : {}),
|
|
957
|
+
},
|
|
958
|
+
(!flat || status !== null)
|
|
959
|
+
? h('span', { className: 'arcv-slot', ...(status !== null ? { title: status.label } : {}) },
|
|
960
|
+
status !== null ? h(StateDot, { state: status.state }) : null)
|
|
961
|
+
: null,
|
|
962
|
+
h('span', { className: 'arcv-title' }, row.displayTitle),
|
|
963
|
+
!row.blank ? h('span', { className: 'arcv-time' }, relativeTime(row.updatedAt, now)) : null,
|
|
964
|
+
h('span', { className: 'arcv-rowActions' },
|
|
965
|
+
h('button', {
|
|
966
|
+
type: 'button', className: 'arcv-iconRow arcv-restoreAction',
|
|
967
|
+
'aria-label': t('restore.aria', { name: row.displayTitle }), title: t('restore'),
|
|
968
|
+
onClick: (e) => { e.stopPropagation(); onRestore(row.id) },
|
|
969
|
+
}, h(Icon, { name: 'Refresh', size: 14 })),
|
|
970
|
+
h('button', {
|
|
971
|
+
type: 'button', className: 'arcv-iconRow arcv-dangerAction',
|
|
972
|
+
'aria-label': t('delete.aria', { name: row.displayTitle }), title: t('delete'),
|
|
973
|
+
onClick: (e) => { e.stopPropagation(); setError(null); setConfirmError(null); setConfirmTarget({ id: row.id, title: row.displayTitle }) },
|
|
974
|
+
}, h(Icon, { name: 'Trash', size: 14 }))))
|
|
975
|
+
}
|
|
976
|
+
return h('div', { className: 'arcv-panel', role: 'dialog', 'aria-label': t('section') },
|
|
977
|
+
h('div', { className: 'arcv-sectionHeader' },
|
|
978
|
+
h('span', { className: 'arcv-sectionLabel arcv-sectionLabelArcv' + (searchExpanded ? ' arcv-sectionLabelHidden' : '') },
|
|
979
|
+
t('section')),
|
|
980
|
+
h('div', { className: 'arcv-searchSlot' + (searchExpanded ? ' arcv-searchSlotExpanded' : '') },
|
|
981
|
+
h('div', {
|
|
982
|
+
className: 'arcv-search' + (searchExpanded ? ' arcv-searchExpanded' : ''),
|
|
983
|
+
ref: searchRootRef,
|
|
984
|
+
onClick: () => { setSearchExpanded(true); if (searchInputRef.current !== null) searchInputRef.current.focus() },
|
|
985
|
+
},
|
|
986
|
+
h('button', {
|
|
987
|
+
type: 'button', className: 'arcv-searchButton',
|
|
988
|
+
'aria-label': t('search.sessions.aria'), 'aria-expanded': searchExpanded, title: t('search.sessions.aria'),
|
|
989
|
+
onClick: (e) => { e.stopPropagation(); setSearchExpanded(true); if (searchInputRef.current !== null) searchInputRef.current.focus() },
|
|
990
|
+
}, h(Icon, { name: 'Search', size: searchExpanded ? 11 : 14 })),
|
|
991
|
+
h('input', {
|
|
992
|
+
ref: searchInputRef,
|
|
993
|
+
className: 'arcv-searchInput',
|
|
994
|
+
type: 'text',
|
|
995
|
+
placeholder: t('search.placeholder'),
|
|
996
|
+
maxLength: 500,
|
|
997
|
+
value: query,
|
|
998
|
+
tabIndex: searchExpanded ? 0 : -1,
|
|
999
|
+
onChange: (e) => setQuery(sanitizeSearchQuery(e.target.value)),
|
|
1000
|
+
onKeyDown: (e) => {
|
|
1001
|
+
if (e.key !== 'Escape') return
|
|
1002
|
+
setQuery('')
|
|
1003
|
+
setSearchExpanded(false)
|
|
1004
|
+
},
|
|
1005
|
+
}),
|
|
1006
|
+
searchExpanded && query !== ''
|
|
1007
|
+
? h('button', {
|
|
1008
|
+
type: 'button', className: 'arcv-clearButton',
|
|
1009
|
+
'aria-label': t('search.clear'),
|
|
1010
|
+
onClick: (e) => { e.stopPropagation(); setQuery(''); setSearchExpanded(false) },
|
|
1011
|
+
}, h(Icon, { name: 'Close', size: 14 }))
|
|
1012
|
+
: null)),
|
|
1013
|
+
h('div', { className: 'arcv-headerActions' + (searchExpanded ? ' arcv-headerActionsHidden' : '') },
|
|
1014
|
+
h(ViewOptionsMenu, {
|
|
1015
|
+
groupBy: view.groupBy, orderBy: view.orderBy,
|
|
1016
|
+
onPick: (id) => {
|
|
1017
|
+
const instance = viewStoreRef.current
|
|
1018
|
+
if (id === 'workspace' || id === 'flat') {
|
|
1019
|
+
if (instance !== null && typeof instance.actions.setGroupBy === 'function') instance.actions.setGroupBy(id)
|
|
1020
|
+
else setView((v) => ({ ...v, groupBy: id }))
|
|
1021
|
+
} else {
|
|
1022
|
+
if (instance !== null && typeof instance.actions.setOrderBy === 'function') instance.actions.setOrderBy(id)
|
|
1023
|
+
else setView((v) => ({ ...v, orderBy: id }))
|
|
1024
|
+
}
|
|
1025
|
+
},
|
|
1026
|
+
}),
|
|
1027
|
+
h('button', {
|
|
1028
|
+
type: 'button', className: 'arcv-iconButton', title: t('close'), 'aria-label': t('close.aria'),
|
|
1029
|
+
onClick: () => panel.set(false),
|
|
1030
|
+
}, h(Icon, { name: 'Close', size: 17 })))),
|
|
1031
|
+
hint !== null ? h('div', { className: 'arcv-hint', onClick: () => setHint(null) }, hint) : null,
|
|
1032
|
+
error !== null ? h('div', { className: 'arcv-error', onClick: () => setError(null) }, error + t('error.dismiss')) : null,
|
|
1033
|
+
// Native SearchResults layout: the status rows live INSIDE the
|
|
1034
|
+
// scrolling list, after the result rows (rows → pending →
|
|
1035
|
+
// warning → no-matches), not above the list.
|
|
1036
|
+
rows.length === 0 && !(searching && searchStatus === 'loading')
|
|
1037
|
+
? h('div', { className: 'arcv-empty' }, searching ? t('empty.noMatches') : t('empty'))
|
|
1038
|
+
: h('div', { className: 'arcv-list' },
|
|
1039
|
+
groups.map((group) => {
|
|
1040
|
+
// Search mode: native two-line result rows, no group folder.
|
|
1041
|
+
if (searching) {
|
|
1042
|
+
return h('div', { className: 'arcv-groupSection', key: group.key },
|
|
1043
|
+
...group.rows.map((row) => {
|
|
1044
|
+
const st = row.blank ? null : sessionStatusOf(row)
|
|
1045
|
+
return h('button', {
|
|
1046
|
+
type: 'button',
|
|
1047
|
+
className: 'arcv-searchResultRow' + (row.id === current ? ' arcv-selected' : ''),
|
|
1048
|
+
key: row.id,
|
|
1049
|
+
role: 'treeitem', 'aria-selected': row.id === current,
|
|
1050
|
+
onClick: () => onOpen(row.id),
|
|
1051
|
+
},
|
|
1052
|
+
h('span', { className: 'arcv-searchResultHeading' },
|
|
1053
|
+
h('span', { className: 'arcv-slot' }, st !== null ? h(StateDot, { state: st.state }) : null),
|
|
1054
|
+
h('span', { className: 'arcv-searchResultTitle' }, row.displayTitle)),
|
|
1055
|
+
h('span', { className: 'arcv-searchResultMeta' },
|
|
1056
|
+
h('span', { className: 'arcv-searchResultWorkspace' }, workspaceTitleOf(row.id)),
|
|
1057
|
+
row.snippet !== undefined ? h('span', { className: 'arcv-searchResultSnippet' }, row.snippet) : null))
|
|
1058
|
+
}))
|
|
1059
|
+
}
|
|
1060
|
+
if (group.flat === true) {
|
|
1061
|
+
return h('div', { className: 'arcv-groupSection', key: group.key },
|
|
1062
|
+
...group.rows.map((r) => renderRow(r, true, group)))
|
|
1063
|
+
}
|
|
1064
|
+
const isExpanded = expandedSet[group.key] === true
|
|
1065
|
+
return h('div', { className: 'arcv-groupSection', key: group.key },
|
|
1066
|
+
h('div', { className: 'arcv-projectRow', onClick: () => setExpandedSet((prev) => ({ ...prev, [group.key]: !isExpanded })) },
|
|
1067
|
+
h('span', { className: 'arcv-slot' + (isExpanded ? ' arcv-folderActive' : '') }, h(Icon, { name: isExpanded ? 'FolderOpen' : 'FolderClose', size: 16 })),
|
|
1068
|
+
h('span', { className: 'arcv-projectText' }, h('span', { className: 'arcv-title' }, group.title))),
|
|
1069
|
+
...(isExpanded ? group.rows : []).map((r) => renderRow(r, false, group)))
|
|
1070
|
+
}),
|
|
1071
|
+
// Status rows trail the result rows inside the list
|
|
1072
|
+
// (native order: rows → pending → warning).
|
|
1073
|
+
searching && searchStatus === 'loading' ? h('div', { className: 'arcv-searchPending', role: 'status' }, t('search.pending')) : null,
|
|
1074
|
+
searching && searchStatus === 'name-only' ? h('div', { className: 'arcv-searchWarning', role: 'status' }, t('search.unavailable')) : null),
|
|
1075
|
+
confirmTarget !== null
|
|
1076
|
+
? h('div', { className: 'arcv-modal-backdrop', role: 'presentation' },
|
|
1077
|
+
h('div', { className: 'arcv-modal-mask', 'aria-hidden': true, onClick: () => { if (!busyRef.current) setConfirmTarget(null) } }),
|
|
1078
|
+
h('div', { className: 'arcv-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('delete.title'), ref: modalRef },
|
|
1079
|
+
h('div', { className: 'arcv-modal-header' },
|
|
1080
|
+
h('h2', { className: 'arcv-modal-title' }, t('delete.title')),
|
|
1081
|
+
h('button', {
|
|
1082
|
+
type: 'button', className: 'arcv-modal-close', 'aria-label': t('close'), disabled: busy,
|
|
1083
|
+
onClick: () => { if (!busyRef.current) setConfirmTarget(null) },
|
|
1084
|
+
}, h(Icon, { name: 'Close', size: 14 }))),
|
|
1085
|
+
h('p', { className: 'arcv-modal-desc' }, t('delete.desc', { name: confirmTarget.title })),
|
|
1086
|
+
busy ? h('div', { className: 'arcv-modal-status', role: 'status' }, t('delete.pending')) : null,
|
|
1087
|
+
confirmError !== null ? h('div', { className: 'arcv-modal-error', role: 'alert' }, confirmError) : null,
|
|
1088
|
+
h('div', { className: 'arcv-modal-footer' },
|
|
1089
|
+
h('button', {
|
|
1090
|
+
type: 'button', className: 'arcv-modal-btn', disabled: busy,
|
|
1091
|
+
onClick: () => { if (!busyRef.current) setConfirmTarget(null) },
|
|
1092
|
+
},
|
|
1093
|
+
t('cancel'), h('span', { className: 'arcv-modal-kbd' }, 'esc')),
|
|
1094
|
+
h('button', {
|
|
1095
|
+
type: 'button', className: 'arcv-modal-btn arcv-modal-btn-danger', disabled: busy,
|
|
1096
|
+
ref: confirmBtnRef, onClick: onConfirmDelete,
|
|
1097
|
+
},
|
|
1098
|
+
t('delete'), h('span', { className: 'arcv-modal-kbd' }, '⏎')))))
|
|
1099
|
+
: null)
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function FooterButton(props) {
|
|
1103
|
+
const open = useStore(panel)
|
|
1104
|
+
const collapsed = useSidebarCollapsed()
|
|
1105
|
+
// Collapsing the sidebar (the rail toggle) closes the archive panel:
|
|
1106
|
+
// the rail has no room for it and a zombie-open state would linger.
|
|
1107
|
+
react.useEffect(() => { if (collapsed) panel.set(false) }, [collapsed])
|
|
1108
|
+
return h('button', {
|
|
1109
|
+
type: 'button',
|
|
1110
|
+
className: 'arcv-footer-btn',
|
|
1111
|
+
'aria-label': t('footer'), title: t('footer'),
|
|
1112
|
+
'aria-expanded': open,
|
|
1113
|
+
onClick: () => {
|
|
1114
|
+
const next = !open
|
|
1115
|
+
// Opening the archive panel closes the native browser's inline
|
|
1116
|
+
// search (the same DOM click would land inside it); closing the
|
|
1117
|
+
// panel resets the panel search via the panel's own effect.
|
|
1118
|
+
if (next) {
|
|
1119
|
+
// :not([class*="arcv"]) — the panel's own input class contains
|
|
1120
|
+
// "searchInput" too and must never be matched here.
|
|
1121
|
+
const input = document.querySelector('input[class*="searchInput"]:not([class*="arcv"])')
|
|
1122
|
+
if (input !== null) {
|
|
1123
|
+
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set
|
|
1124
|
+
setter.call(input, '')
|
|
1125
|
+
input.dispatchEvent(new window.Event('input', { bubbles: true }))
|
|
1126
|
+
}
|
|
1127
|
+
// footer.action slot props carry only { wide }; expansion goes
|
|
1128
|
+
// through the layout service (the official sidebar channel).
|
|
1129
|
+
if (collapsed) {
|
|
1130
|
+
const layout = ctx.get('layout')
|
|
1131
|
+
if (layout !== undefined && typeof layout.toggleSidebar === 'function') layout.toggleSidebar()
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
panel.set(next)
|
|
1135
|
+
},
|
|
1136
|
+
},
|
|
1137
|
+
h(Icon, { name: 'Archive', size: props.wide ? 16 : 18 }),
|
|
1138
|
+
props.wide ? h('span', { className: 'arcv-footer-label' }, t('footer')) : null)
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// The disposer is the ONLY cleanup channel for inject: slots.inject
|
|
1142
|
+
// registers its effect on the service's own context, not this fiber —
|
|
1143
|
+
// dropping it would leave the footer entry (pointing at dead host
|
|
1144
|
+
// routes) resident after unload.
|
|
1145
|
+
cleanups.push(slots.inject('sidebar.footer.action', () => slots.register(
|
|
1146
|
+
{ name: 'sidebar.footer.action', id: 'archive-view-entry', label: t('footer') },
|
|
1147
|
+
(props) => h(react.Fragment, null, h(FooterButton, { ...props }), h(ArchivePanel, { ...props })),
|
|
1148
|
+
)))
|
|
1149
|
+
return () => {
|
|
1150
|
+
// Each step isolated: one failing disposer must not skip the rest.
|
|
1151
|
+
for (const restore of restorePatches) { try { restore() } catch (e) {} }
|
|
1152
|
+
for (const dispose of cleanups) { try { dispose() } catch (e) {} }
|
|
1153
|
+
styleEl.remove()
|
|
1154
|
+
}
|
|
1155
|
+
} catch (error) {
|
|
1156
|
+
try { console.warn('[archive-manager] client apply failed (skipped):', error) } catch (e) {}
|
|
1157
|
+
}
|
|
1158
|
+
},
|
|
1159
|
+
}
|
|
1160
|
+
return client
|
|
1161
|
+
},
|
|
1162
|
+
})
|