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/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
+ })