dsh-up-session 0.0.0-stage → 1.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/client.js ADDED
@@ -0,0 +1,1977 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-up-session',
3
+ factory(require) {
4
+ const React = require('react');
5
+ const { createPortal } = require('react-dom');
6
+ const h = React.createElement;
7
+
8
+ /** Dictionary namespace owned by this plugin. */
9
+ const NS = 'upSession';
10
+ /** Services required to read pin state, render, and pin, unpin, or archive. */
11
+ const inject = ['slots', 'locale', 'uiWorkspace'];
12
+
13
+ /** Simplified Chinese dictionary and key source of truth. */
14
+ const zh = {
15
+ section: '置顶',
16
+ recent: '最近',
17
+ expand: '展开置顶会话',
18
+ collapse: '收起置顶会话',
19
+ recentExpand: '展开最近会话',
20
+ recentCollapse: '收起最近会话',
21
+ empty: '无置顶会话',
22
+ recentEmpty: '无最近会话',
23
+ showMore: '展开更多',
24
+ showLess: '收起',
25
+ settings: '设置',
26
+ settingsTitle: '设置',
27
+ settingsOpen: '打开设置',
28
+ settingsEntryDesc: '管理列表显示和打开会话时的工作区展开行为。',
29
+ settingsClose: '关闭设置',
30
+ settingsShowPinned: '显示置顶',
31
+ settingsShowPinnedDesc: '在侧边栏显示置顶会话列表。',
32
+ settingsShowRecent: '显示最近',
33
+ settingsShowRecentDesc: '在侧边栏显示最近会话列表。',
34
+ settingsExpandWorkspace: '展开工作区中的原会话',
35
+ settingsExpandWorkspaceDesc:
36
+ '从置顶或最近区打开会话时,同时展开它在工作区列表中的位置。关闭后只打开会话,不改变工作区的展开状态。',
37
+ untitled: '未命名',
38
+ pin: '置顶',
39
+ pinned: '已置顶',
40
+ pinNamed: '置顶 {title}',
41
+ unpin: '取消置顶',
42
+ unpinNamed: '取消置顶 {title}',
43
+ archive: '归档会话',
44
+ archiveNamed: '归档 {title}',
45
+ openNamed: '打开 {title}',
46
+ openNamedStatus: '打开 {title}({status})',
47
+ 'status.running': '进行中',
48
+ 'status.completed': '已完成',
49
+ 'status.waitingApproval': '等待审批',
50
+ 'status.planReview': '计划待审',
51
+ 'status.waitingAnswer': '等待回答',
52
+ 'status.subagents.one': '{n} 个子智能体运行中',
53
+ 'status.subagents.other': '{n} 个子智能体运行中',
54
+ 'archiveConfirm.title': '停止并归档此会话?',
55
+ 'archiveConfirm.desc':
56
+ '“{title}”仍有正在进行的工作。归档会先停止这些工作并同时取消置顶;之后可在侧栏“全部对话(显示已归档)”中恢复会话,但被停止的工作不会继续,置顶也不会恢复。',
57
+ 'archiveConfirm.activity': '将被停止的工作',
58
+ 'archiveConfirm.turn': '进行中的回合',
59
+ 'archiveConfirm.subagents.one': '{n} 个运行中的子智能体:{names}',
60
+ 'archiveConfirm.subagents.other': '{n} 个运行中的子智能体:{names}',
61
+ 'archiveConfirm.jobs.one': '{n} 个后台任务:{names}',
62
+ 'archiveConfirm.jobs.other': '{n} 个后台任务:{names}',
63
+ 'archiveConfirm.schedules.one': '{n} 条定时提醒:{names}',
64
+ 'archiveConfirm.schedules.other': '{n} 条定时提醒:{names}',
65
+ 'archiveConfirm.other.one': '{n} 项其他工作({kind})',
66
+ 'archiveConfirm.other.other': '{n} 项其他工作({kind})',
67
+ 'archiveConfirm.listSeparator': '、',
68
+ 'archiveConfirm.action': '停止并归档',
69
+ 'archiveConfirm.pending': '正在停止并归档…',
70
+ 'archiveConfirm.cancel': '取消',
71
+ 'archiveConfirm.close': '关闭',
72
+ 'time.now': '刚刚',
73
+ 'time.minutes': '{n}分钟',
74
+ 'time.hours': '{n}小时',
75
+ 'time.days': '{n}天',
76
+ 'time.months': '{n}个月',
77
+ 'time.years': '{n}年',
78
+ };
79
+ /** English dictionary checked against the Chinese key set. */
80
+ const en = {
81
+ section: 'Pinned',
82
+ recent: 'Recent',
83
+ expand: 'Expand pinned sessions',
84
+ collapse: 'Collapse pinned sessions',
85
+ recentExpand: 'Expand recent sessions',
86
+ recentCollapse: 'Collapse recent sessions',
87
+ empty: 'No pinned sessions',
88
+ recentEmpty: 'No recent sessions',
89
+ showMore: 'Show more',
90
+ showLess: 'Show less',
91
+ settings: 'Settings',
92
+ settingsTitle: 'Settings',
93
+ settingsOpen: 'Open settings',
94
+ settingsEntryDesc: 'Manage list visibility and workspace expansion when opening sessions.',
95
+ settingsClose: 'Close settings',
96
+ settingsShowPinned: 'Show Pinned',
97
+ settingsShowPinnedDesc: 'Show the pinned session list in the sidebar.',
98
+ settingsShowRecent: 'Show Recent',
99
+ settingsShowRecentDesc: 'Show the recent session list in the sidebar.',
100
+ settingsExpandWorkspace: 'Reveal the session in its workspace',
101
+ settingsExpandWorkspaceDesc:
102
+ 'When you open a session from Pinned or Recent, also expand its workspace in the sidebar. Turn this off to open the session without changing which workspaces are expanded.',
103
+ untitled: 'Untitled',
104
+ pin: 'Pin',
105
+ pinned: 'Pinned',
106
+ pinNamed: 'Pin {title}',
107
+ unpin: 'Unpin',
108
+ unpinNamed: 'Unpin {title}',
109
+ archive: 'Archive session',
110
+ archiveNamed: 'Archive {title}',
111
+ openNamed: 'Open {title}',
112
+ openNamedStatus: 'Open {title} ({status})',
113
+ 'status.running': 'Running',
114
+ 'status.completed': 'Completed',
115
+ 'status.waitingApproval': 'Waiting for approval',
116
+ 'status.planReview': 'Plan awaiting review',
117
+ 'status.waitingAnswer': 'Waiting for answer',
118
+ 'status.subagents.one': '{n} subagent running',
119
+ 'status.subagents.other': '{n} subagents running',
120
+ 'archiveConfirm.title': 'Stop and archive this session?',
121
+ 'archiveConfirm.desc':
122
+ '“{title}” still has work in progress. Archiving stops it first and also unpins the session; you can restore the session later from the “All conversations (show archived)” filter in the sidebar, but the stopped work will not resume and the pin will not come back.',
123
+ 'archiveConfirm.activity': 'Work that will be stopped',
124
+ 'archiveConfirm.turn': 'The turn in progress',
125
+ 'archiveConfirm.subagents.one': '{n} running subagent: {names}',
126
+ 'archiveConfirm.subagents.other': '{n} running subagents: {names}',
127
+ 'archiveConfirm.jobs.one': '{n} background job: {names}',
128
+ 'archiveConfirm.jobs.other': '{n} background jobs: {names}',
129
+ 'archiveConfirm.schedules.one': '{n} scheduled reminder: {names}',
130
+ 'archiveConfirm.schedules.other': '{n} scheduled reminders: {names}',
131
+ 'archiveConfirm.other.one': '{n} other item of work ({kind})',
132
+ 'archiveConfirm.other.other': '{n} other items of work ({kind})',
133
+ 'archiveConfirm.listSeparator': ', ',
134
+ 'archiveConfirm.action': 'Stop and archive',
135
+ 'archiveConfirm.pending': 'Stopping and archiving…',
136
+ 'archiveConfirm.cancel': 'Cancel',
137
+ 'archiveConfirm.close': 'Close',
138
+ 'time.now': 'now',
139
+ 'time.minutes': '{n}min',
140
+ 'time.hours': '{n}h',
141
+ 'time.days': '{n}d',
142
+ 'time.months': '{n}mo',
143
+ 'time.years': '{n}y',
144
+ };
145
+
146
+ /** Stylesheet, scoped to this plugin's own class names. */
147
+ const CSS = [
148
+ // Right edge is measured at runtime from a native session row (see
149
+ // alignToNativeRows): arithmetic on the host's padding chain got this wrong
150
+ // twice, so the section copies the real geometry instead of predicting it.
151
+ '.dshUS-mount{box-sizing:border-box;flex:none;display:flex;flex-direction:column;gap:4px;',
152
+ 'padding-left:4px;margin:0 0 4px -4px}',
153
+ '.dshUS-mount[data-rail="true"]{display:none}',
154
+ '.dshUS-root{box-sizing:border-box;display:flex;flex-direction:column;gap:2px}',
155
+ // The header copies the host's "Workspaces" section header: same box, same
156
+ // 14px/20px label, same tertiary label colour. The whole row is the toggle,
157
+ // and the settings entry floats over its right edge without taking space,
158
+ // so revealing it on hover never shifts the label or the chevron.
159
+ '.dshUS-headerRow{box-sizing:border-box;position:relative;display:flex;align-items:center;',
160
+ 'height:36px;margin:0 0 4px}',
161
+ '.dshUS-header{box-sizing:border-box;display:flex;align-items:center;gap:4px;width:100%;',
162
+ 'height:100%;margin:0;padding:0 4px 0 4px;border:0;background:0 0;font:inherit;',
163
+ 'text-align:left;color:var(--dsw-alias-label-tertiary);font-size:14px;line-height:20px;',
164
+ 'overflow:hidden;user-select:none;cursor:pointer;border-radius:var(--dsw-radius-sm,4px)}',
165
+ '.dshUS-header:hover,',
166
+ '.dshUS-headerRow:hover .dshUS-header{color:var(--dsw-alias-label-secondary)}',
167
+ '.dshUS-header:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid ',
168
+ 'var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#4d6bfe));outline-offset:-2px}',
169
+ // The section's settings entry: the host's own gear, kept out of the way
170
+ // until the header row is hovered or holds keyboard focus. It floats over
171
+ // the header's empty right side, so the header keeps its full hit area and
172
+ // nothing reflows when it appears. A touch surface never hovers, so there
173
+ // it stays visible, exactly as the row actions do.
174
+ '.dshUS-settingsButton{box-sizing:border-box;position:absolute;top:50%;right:4px;',
175
+ 'z-index:1;transform:translateY(-50%);display:inline-flex;align-items:center;justify-content:center;',
176
+ 'width:24px;height:24px;padding:0;border:0;border-radius:var(--dsw-radius-sm,4px);',
177
+ 'background:0 0;cursor:pointer;color:var(--dsw-alias-label-tertiary);',
178
+ 'visibility:hidden;opacity:0;transition:opacity .12s var(--ds-ease-in-out)}',
179
+ '.dshUS-headerRow:hover .dshUS-settingsButton,',
180
+ '.dshUS-header:focus-visible + .dshUS-settingsButton,',
181
+ '.dshUS-settingsButton:focus-visible{visibility:visible;opacity:1}',
182
+ '.dshUS-settingsButton:hover{background:var(--dsw-alias-interactive-bg-hover);',
183
+ 'color:var(--dsw-alias-label-primary)}',
184
+ '.dshUS-settingsButton:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid ',
185
+ 'var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#4d6bfe));outline-offset:1px}',
186
+ '@media (prefers-reduced-motion:reduce){.dshUS-settingsButton{transition:none}}',
187
+ '@media (hover:none){.dshUS-settingsButton{visibility:visible;opacity:1}}',
188
+ '.dshUS-label{flex:none;min-width:0;overflow:hidden;text-overflow:ellipsis;',
189
+ 'white-space:nowrap}',
190
+ // A V-shaped chevron: points down when expanded, back right when collapsed,
191
+ // rotating on the host's own easing curve.
192
+ '.dshUS-arrow{flex:none;display:inline-flex;align-items:center;justify-content:center;',
193
+ 'color:var(--dsw-alias-label-caption,currentColor);',
194
+ 'transition:transform .15s var(--ds-ease-in-out);transform:rotate(0deg)}',
195
+ '.dshUS-arrow[data-open="false"]{transform:rotate(-90deg)}',
196
+ '@media (prefers-reduced-motion:reduce){.dshUS-arrow{transition:none}}',
197
+ // Row geometry copied from the host's session row: zero gap (the host's
198
+ // row sets `gap:0`), the title's own 4/6px margins around it, and a
199
+ // pin-sized 16x20 trailing box with a 6px lead — the same box the host's
200
+ // own pinned marker occupies.
201
+ '.dshUS-row{box-sizing:border-box;display:flex;align-items:center;gap:0;height:32px;',
202
+ 'padding:0 8px;border-radius:var(--dsw-radius-md,8px);cursor:pointer;font:inherit;',
203
+ 'font-size:14px;line-height:20px;color:var(--dsw-alias-label-primary);background:0 0;',
204
+ 'border:0;text-align:left;width:100%}',
205
+ '.dshUS-row:hover{background:var(--dsw-alias-interactive-bg-hover)}',
206
+ // Matches the sidebar's own selected row (same surface, same label weight).
207
+ '.dshUS-current{background:var(--dsw-alias-interactive-bg-hover);',
208
+ 'color:var(--dsw-alias-label-primary)}',
209
+ '.dshUS-row:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid ',
210
+ 'var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#4d6bfe));outline-offset:-2px}',
211
+ '.dshUS-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;',
212
+ 'margin:0 6px 0 4px;font-size:14px;line-height:20px}',
213
+ // The status cell sits at the row's leading edge, exactly where the
214
+ // sidebar reserves its own 16x20 slot, and appears only on a row that has
215
+ // something to report. An idle row renders no cell at all, so its title
216
+ // keeps the section's own left edge instead of the sidebar's deeper
217
+ // indent: the marker is what the user asked for, not a right shift on
218
+ // every quiet row. The relative time keeps its place, because it says
219
+ // when the row last moved rather than what it is doing.
220
+ '.dshUS-status{flex:none;display:inline-flex;align-items:center;justify-content:center;',
221
+ 'width:16px;height:20px;color:var(--dsw-alias-label-tertiary)}',
222
+ // The host's ongoing loader: a 14px ring whose arc sweeps while the
223
+ // Session runs, drawn with the primitives' own stroke recipe and easing
224
+ // so both rows animate identically.
225
+ '.dshUS-spinner{flex:none;color:var(--dsw-alias-label-tertiary)}',
226
+ '.dshUS-spinnerMotion{transform-origin:center;animation:dshUS-spin 1.5s linear infinite}',
227
+ '.dshUS-spinnerTrack,.dshUS-spinnerArc{fill:none;stroke:currentColor;stroke-width:2;',
228
+ 'stroke-linecap:round}',
229
+ '.dshUS-spinnerTrack{opacity:.25}',
230
+ '.dshUS-spinnerArc{stroke-dasharray:12 150;animation:dshUS-dash 1.5s ease-in-out infinite}',
231
+ '@keyframes dshUS-spin{to{transform:rotate(360deg)}}',
232
+ '@keyframes dshUS-dash{0%{stroke-dasharray:12 150;stroke-dashoffset:0}',
233
+ '50%{stroke-dasharray:24 150;stroke-dashoffset:-6}',
234
+ 'to{stroke-dasharray:12 150;stroke-dashoffset:0}}',
235
+ // A status dot is the host's 10px box with a 20% inset circle inside it, so
236
+ // the mark itself is 6px: done is the success green, a pending interaction
237
+ // the warning amber, exactly the colours the sidebar's own StateDot paints.
238
+ '.dshUS-dot{position:relative;display:inline-block;flex:none;width:10px;height:10px}',
239
+ '.dshUS-dot:after{content:"";position:absolute;inset:20%;border-radius:50%;',
240
+ 'corner-shape:round;background:currentColor}',
241
+ '.dshUS-dot[data-state="done"]{color:var(--dsw-alias-state-success-primary)}',
242
+ '.dshUS-dot[data-state="warning"]{color:var(--dsw-alias-state-warn-primary)}',
243
+ '@media (prefers-reduced-motion:reduce){.dshUS-spinnerMotion,.dshUS-spinnerArc{animation:none}',
244
+ '.dshUS-spinnerArc{stroke-dasharray:18 150;stroke-dashoffset:-3}}',
245
+ '.dshUS-time{flex:none;margin:0;color:var(--dsw-alias-label-tertiary);font-size:10px;',
246
+ 'line-height:16px}',
247
+ // The host's own pinned marker, kept on the row at rest exactly as the
248
+ // sidebar keeps its marker on a pinned Session: same 16px box, same 6px
249
+ // lead, same caption colour. It is the trailing cell of a pinned row, so
250
+ // the title is measured against it even while the actions are hidden —
251
+ // dropping it would widen the title past the sidebar's and hide the
252
+ // ellipsis the sidebar draws. Hovering swaps it for the row's actions,
253
+ // the way the sidebar swaps its marker for its menu.
254
+ '.dshUS-pinMark{flex:none;display:inline-flex;align-items:center;justify-content:center;',
255
+ 'width:16px;height:20px;margin-left:6px;color:var(--dsw-alias-label-caption,currentColor)}',
256
+ // The row's trailing actions sit in one group that swallows clicks, so a
257
+ // button press never also opens the row (the host's rows do the same).
258
+ // The group hides as a whole rather than each button: `display:none`
259
+ // removes its margin too, so a row whose trailing cells are all absent
260
+ // ends with the time flush against the row edge, as the host's own rows
261
+ // do. Showing the group on the row's focus is what keeps its buttons
262
+ // reachable, since a `display:none` button cannot be tabbed to.
263
+ '.dshUS-actions{flex:none;display:none;align-items:center;gap:8px;margin-left:6px}',
264
+ // Placeholder that fills the row the section would otherwise not draw.
265
+ // It spans space without any hover surface, so nothing looks clickable:
266
+ // the title keeps the native row's indent and the text stays at the
267
+ // sidebar's tertiary label colour.
268
+ '.dshUS-empty{box-sizing:border-box;display:flex;align-items:center;height:32px;',
269
+ 'padding:0 8px;cursor:default}',
270
+ '.dshUS-emptyLabel{flex:1;min-width:0;margin:0 6px 0 4px;overflow:hidden;',
271
+ 'text-overflow:ellipsis;white-space:nowrap;font-size:14px;line-height:20px;',
272
+ 'color:var(--dsw-alias-label-tertiary)}',
273
+ // The host's own "Show more" row: one full-width 28px text button whose
274
+ // label lines up with the Session titles above it.
275
+ '.dshUS-more{box-sizing:border-box;display:block;width:100%;height:28px;margin:0;',
276
+ 'padding:0 12px;border:0;border-radius:var(--dsw-radius-sm,4px);background:0 0;font:inherit;',
277
+ 'font-size:12px;line-height:18px;text-align:left;color:var(--dsw-alias-label-tertiary);',
278
+ 'cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
279
+ '.dshUS-more:hover{color:var(--dsw-alias-label-secondary)}',
280
+ '.dshUS-more:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid ',
281
+ 'var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#4d6bfe));outline-offset:-2px}',
282
+ // A row's actions stay out of its resting state and appear on hover or
283
+ // once the row holds keyboard focus, the way the host's own session rows
284
+ // reveal theirs. `:focus-within` is what keeps them reachable: the group
285
+ // cannot be tabbed into while `display:none`, and this rule holds it
286
+ // visible from the row's own focus all the way onto the buttons.
287
+ '.dshUS-action{box-sizing:border-box;flex:none;display:inline-flex;align-items:center;',
288
+ 'justify-content:center;width:16px;height:20px;padding:0;border:0;cursor:pointer;',
289
+ 'border-radius:var(--dsw-radius-xs,2px);background:0 0;',
290
+ 'color:var(--dsw-alias-label-caption,currentColor)}',
291
+ '.dshUS-row:hover .dshUS-actions,',
292
+ '.dshUS-row:focus-visible .dshUS-actions,',
293
+ '.dshUS-row:focus-within .dshUS-actions{display:inline-flex}',
294
+ '.dshUS-action:hover{color:var(--dsw-alias-label-primary)}',
295
+ '.dshUS-action:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid ',
296
+ 'var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#4d6bfe));outline-offset:1px}',
297
+ // The relative time and the pinned marker are what the actions replace, so
298
+ // they go away under exactly the conditions that bring the actions in —
299
+ // the host's session rows drop their time and pin marker on hover for the
300
+ // same reason. With both gone the actions keep the row's trailing edge.
301
+ '.dshUS-row:hover .dshUS-time,',
302
+ '.dshUS-row:focus-visible .dshUS-time,',
303
+ '.dshUS-row:focus-within .dshUS-time,',
304
+ '.dshUS-row:hover .dshUS-pinMark,',
305
+ '.dshUS-row:focus-visible .dshUS-pinMark,',
306
+ '.dshUS-row:focus-within .dshUS-pinMark{display:none}',
307
+ // A touch surface never hovers, so the actions stay visible there and the
308
+ // time and marker stay hidden with them.
309
+ '@media (hover:none){.dshUS-actions{display:inline-flex}',
310
+ '.dshUS-time,.dshUS-pinMark{display:none}}',
311
+ // The plugin's settings popup, rebuilt on the host's own settings panel:
312
+ // the 800px panel narrowed for a single page, the mask and elevation, the
313
+ // 54px header with a 28px close, and the content's 24px inset. The page
314
+ // itself holds one preference row with the host's 36x20 switch.
315
+ '.dshUS-settingsRoot{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;',
316
+ 'justify-content:center;padding:24px}',
317
+ '.dshUS-settingsMask{position:absolute;inset:var(--dsh-frame-chrome-top,0px) 0 0;',
318
+ 'background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.45));backdrop-filter:var(--dsw-mask-blur,blur(4px))}',
319
+ '.dshUS-settingsPanel{box-sizing:border-box;position:relative;z-index:1;display:flex;',
320
+ 'flex-direction:column;width:min(560px,100%);max-height:100%;overflow:hidden;',
321
+ 'border-radius:var(--dsw-radius-panel,var(--dsw-radius-lg,8px));',
322
+ 'background:var(--dsw-alias-bg-layer-2);',
323
+ 'box-shadow:var(--dsw-elevation-prominent,0 8px 32px rgba(0,0,0,.24));',
324
+ 'animation:dshUS-dialogIn var(--ds-transition-duration,.15s) var(--ds-ease-in-out)}',
325
+ '.dshUS-settingsPanel:focus{outline:none}',
326
+ '.dshUS-settingsHeader{box-sizing:border-box;flex:none;display:flex;align-items:flex-start;',
327
+ 'justify-content:space-between;gap:8px;height:54px;padding:20px 14px 8px 24px}',
328
+ '.dshUS-settingsTitle{margin:0;font-size:16px;line-height:24px;font-weight:500;',
329
+ 'color:var(--dsw-alias-label-primary)}',
330
+ '.dshUS-settingsHeader .dshUS-dialogClose{color:var(--dsw-alias-label-primary)}',
331
+ '.dshUS-settingsBody{flex:1;min-height:0;padding:0 24px 24px;overflow-y:auto;',
332
+ 'overscroll-behavior:contain}',
333
+ '.dshUS-settingsRow{display:flex;align-items:center;justify-content:space-between;gap:24px;',
334
+ 'padding:16px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}',
335
+ '.dshUS-settingsRow:last-child{border-bottom:none}',
336
+ '.dshUS-settingsCopy{min-width:0}',
337
+ '.dshUS-settingsRowTitle{font-size:14px;line-height:20px;color:var(--dsw-alias-label-primary)}',
338
+ '.dshUS-settingsRowDesc{margin-top:4px;font-size:12px;line-height:18px;',
339
+ 'color:var(--dsw-alias-label-secondary)}',
340
+ // The host's switch, copied from its primitives: a 36x20 track with a 16px
341
+ // thumb that slides on the brand surface when checked.
342
+ '.dshUS-switch{box-sizing:border-box;position:relative;flex:none;width:36px;height:20px;',
343
+ 'padding:2px;border:0;border-radius:999px;corner-shape:round;',
344
+ 'background:var(--dsw-alias-border-l3);cursor:pointer}',
345
+ '.dshUS-switch[aria-checked="true"]{background:var(--dsw-alias-brand-primary)}',
346
+ '.dshUS-switch:disabled{cursor:not-allowed;opacity:.4}',
347
+ '.dshUS-switch:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid ',
348
+ 'var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#4d6bfe));outline-offset:2px}',
349
+ '.dshUS-switchThumb{display:block;width:16px;height:16px;border-radius:50%;corner-shape:round;',
350
+ 'background:var(--dsw-alias-label-primary-foreground);transition:transform .12s ease}',
351
+ '.dshUS-switch[aria-checked="false"] .dshUS-switchThumb{',
352
+ 'background:var(--dsw-alias-switch-thumb)}',
353
+ '.dshUS-switch[aria-checked="true"] .dshUS-switchThumb{transform:translate(16px)}',
354
+ '@media (prefers-reduced-motion:reduce){.dshUS-switchThumb{transition:none}}',
355
+ // "Stop and archive" confirmation: the host's Modal geometry (380px panel,
356
+ // layer-2 surface, 24px body inset) narrowed to the host's confirmation
357
+ // width, all painted with theme tokens.
358
+ '.dshUS-dialogRoot{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;',
359
+ 'justify-content:center;padding:24px}',
360
+ '.dshUS-dialogMask{position:absolute;inset:0;background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.45))}',
361
+ '.dshUS-dialog{box-sizing:border-box;position:relative;z-index:1;display:flex;',
362
+ 'flex-direction:column;gap:20px;width:min(440px,100%);max-height:100%;padding:0 0 24px;',
363
+ 'overflow:hidden;border-radius:var(--dsw-radius-lg,8px);background:var(--dsw-alias-bg-layer-2);',
364
+ 'box-shadow:var(--dsw-elevation-prominent,0 8px 32px rgba(0,0,0,.24));',
365
+ 'animation:dshUS-dialogIn var(--ds-transition-duration,.15s) var(--ds-ease-in-out)}',
366
+ '@keyframes dshUS-dialogIn{0%{opacity:0}to{opacity:1}}',
367
+ '@media (prefers-reduced-motion:reduce){.dshUS-dialog,.dshUS-settingsPanel{animation:none}}',
368
+ '.dshUS-dialog:focus{outline:none}',
369
+ '.dshUS-dialogHeader{display:flex;align-items:center;justify-content:space-between;gap:8px;',
370
+ 'padding:22px 14px 12px 24px}',
371
+ '.dshUS-dialogTitle{margin:0;font-size:16px;line-height:24px;font-weight:500;',
372
+ 'color:var(--dsw-alias-label-primary)}',
373
+ '.dshUS-dialogClose{flex:none;display:inline-flex;align-items:center;justify-content:center;',
374
+ 'width:28px;height:28px;border:0;border-radius:var(--dsw-radius-sm,4px);background:0 0;',
375
+ 'cursor:pointer;color:var(--dsw-alias-label-secondary)}',
376
+ '.dshUS-dialogClose:hover{background:var(--dsw-alias-interactive-bg-hover)}',
377
+ '.dshUS-dialogClose:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid ',
378
+ 'var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#4d6bfe));outline-offset:1px}',
379
+ '.dshUS-dialogBody{display:flex;flex-direction:column;min-width:0;margin-top:12px;',
380
+ 'padding:0 24px;max-height:60vh;overflow-y:auto;overscroll-behavior:contain}',
381
+ '.dshUS-dialogDesc{margin:0;font-size:14px;line-height:22px;',
382
+ 'color:var(--dsw-alias-label-primary)}',
383
+ '.dshUS-activity{color:var(--dsw-alias-label-primary);margin:0 0 8px;padding-left:18px;',
384
+ 'font-size:13px;line-height:20px}',
385
+ '.dshUS-activity li{overflow-wrap:anywhere}',
386
+ '.dshUS-dialogStatus{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}',
387
+ '.dshUS-dialogError{color:var(--dsw-alias-state-error-primary);margin-top:8px;font-size:12px;',
388
+ 'line-height:18px}',
389
+ '.dshUS-dialogFooter{display:flex;align-items:center;justify-content:flex-end;gap:8px;',
390
+ 'padding:0 24px}',
391
+ '.dshUS-button{box-sizing:border-box;display:inline-flex;align-items:center;',
392
+ 'justify-content:center;gap:4px;height:36px;padding:0 14px;border:0;cursor:pointer;',
393
+ 'border-radius:var(--dsw-radius-md,8px);font:inherit;font-size:14px;line-height:22px;',
394
+ 'color:var(--dsw-alias-label-primary);background:transparent}',
395
+ '.dshUS-button:disabled{cursor:not-allowed;opacity:.4}',
396
+ '.dshUS-buttonOutline{border:.5px solid var(--dsw-alias-border-l3)}',
397
+ '.dshUS-buttonOutline:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
398
+ '.dshUS-buttonDanger:not(:disabled){color:var(--dsw-alias-state-error-primary)}',
399
+ ].join('');
400
+
401
+ /**
402
+ * The host's own pin glyph (the one the session rows use), copied verbatim
403
+ * so the section matches the sidebar without importing a Client package.
404
+ * @param props - square edge in pixels.
405
+ * @returns the filled pin icon.
406
+ */
407
+ function PinGlyph({ size }) {
408
+ return h(
409
+ 'svg',
410
+ {
411
+ width: size,
412
+ height: size,
413
+ viewBox: '0 0 16 16',
414
+ fill: 'none',
415
+ xmlns: 'http://www.w3.org/2000/svg',
416
+ 'aria-hidden': 'true',
417
+ strokeWidth: 1,
418
+ },
419
+ h('path', {
420
+ d: 'M9.96976 1.70572L13.1554 3.93629L10.9019 8.12317L11.5158 11.605L10.7192 12.7427L2.52767 7.00693L3.3243 5.86922L6.80612 5.25528L9.96976 1.70572Z',
421
+ fill: 'currentColor',
422
+ stroke: 'currentColor',
423
+ strokeLinejoin: 'round',
424
+ }),
425
+ h('path', {
426
+ d: 'M6.05285 9.47511C6.27284 9.16094 6.70586 9.08458 7.02003 9.30457C7.3342 9.52455 7.41055 9.95757 7.19057 10.2717L3.98587 14.4708L3.21223 13.9291L6.05285 9.47511Z',
427
+ fill: 'currentColor',
428
+ }),
429
+ );
430
+ }
431
+
432
+ /**
433
+ * The host's own archive glyph (a lidded box), copied verbatim so the row
434
+ * action reads like the sidebar's own rather than importing a Client package.
435
+ * @param props - square edge in pixels.
436
+ * @returns the outline archive icon.
437
+ */
438
+ function ArchiveGlyph({ size }) {
439
+ return h(
440
+ 'svg',
441
+ {
442
+ width: size,
443
+ height: size,
444
+ viewBox: '0 0 16 16',
445
+ fill: 'none',
446
+ xmlns: 'http://www.w3.org/2000/svg',
447
+ 'aria-hidden': 'true',
448
+ strokeWidth: 1,
449
+ },
450
+ h('path', {
451
+ d: 'M13.5 2.5H2.5C1.94772 2.5 1.5 2.94772 1.5 3.5V4.5C1.5 5.05228 1.94772 5.5 2.5 5.5H13.5C14.0523 5.5 14.5 5.05228 14.5 4.5V3.5C14.5 2.94772 14.0523 2.5 13.5 2.5Z',
452
+ stroke: 'currentColor',
453
+ }),
454
+ h('path', {
455
+ d: 'M2.5 5.5V13.5C2.5 13.7652 2.60536 14.0196 2.79289 14.2071C2.98043 14.3946 3.23478 14.5 3.5 14.5H12.5C12.7652 14.5 13.0196 14.3946 13.2071 14.2071C13.3946 14.0196 13.5 13.7652 13.5 13.5V5.5',
456
+ stroke: 'currentColor',
457
+ }),
458
+ h('path', { d: 'M6.5 9.5H9.5', stroke: 'currentColor' }),
459
+ );
460
+ }
461
+
462
+ /**
463
+ * The host's modal close glyph, copied for the confirmation dialog.
464
+ * @param props - square edge in pixels.
465
+ * @returns the crossed icon.
466
+ */
467
+ function CloseGlyph({ size }) {
468
+ return h(
469
+ 'svg',
470
+ {
471
+ width: size,
472
+ height: size,
473
+ viewBox: '0 0 16 16',
474
+ fill: 'none',
475
+ xmlns: 'http://www.w3.org/2000/svg',
476
+ 'aria-hidden': 'true',
477
+ strokeWidth: 1,
478
+ },
479
+ h('path', { d: 'M3.5 3.5L12.5 12.5', stroke: 'currentColor' }),
480
+ h('path', { d: 'M12.5 3.5L3.5 12.5', stroke: 'currentColor' }),
481
+ );
482
+ }
483
+
484
+ /**
485
+ * The host's own settings gear, copied from the primitives so the section
486
+ * entry matches the host's settings trigger without importing a Client
487
+ * package.
488
+ * @param props - square edge in pixels.
489
+ * @returns the outline gear icon.
490
+ */
491
+ function SettingsGlyph({ size }) {
492
+ return h(
493
+ 'svg',
494
+ {
495
+ width: size,
496
+ height: size,
497
+ viewBox: '0 0 16 16',
498
+ fill: 'none',
499
+ xmlns: 'http://www.w3.org/2000/svg',
500
+ 'aria-hidden': 'true',
501
+ strokeWidth: 1.3,
502
+ },
503
+ h('path', {
504
+ d: 'M8 9.75012C8.9665 9.75012 9.75 8.96662 9.75 8.00012C9.75 7.03362 8.9665 6.25012 8 6.25012C7.0335 6.25012 6.25 7.03362 6.25 8.00012C6.25 8.96662 7.0335 9.75012 8 9.75012Z',
505
+ stroke: 'currentColor',
506
+ }),
507
+ h('path', {
508
+ d: 'M13.0107 7.79377C12.9505 7.89401 12.9205 7.94413 12.9205 7.99951C12.9205 8.0549 12.9505 8.10502 13.0106 8.20528L13.9849 9.83006C14.045 9.93029 14.0751 9.9804 14.0751 10.0358C14.0751 10.0911 14.045 10.1413 13.9849 10.2415L13.0037 11.8777C12.9468 11.9726 12.9184 12.0201 12.8725 12.0461C12.8267 12.072 12.7713 12.072 12.6607 12.072H10.6704C10.5598 12.072 10.5045 12.072 10.4586 12.098C10.4128 12.1239 10.3843 12.1714 10.3274 12.2662L9.33825 13.9142C9.28133 14.009 9.25287 14.0564 9.20703 14.0823C9.16118 14.1083 9.10588 14.1083 8.99529 14.1083H7.00486C6.89426 14.1083 6.83896 14.1083 6.79312 14.0823C6.74727 14.0564 6.71881 14.009 6.6619 13.9142L5.67273 12.2662C5.61581 12.1714 5.58735 12.1239 5.54151 12.098C5.49566 12.072 5.44036 12.072 5.32977 12.072H3.33945C3.2288 12.072 3.17347 12.072 3.12761 12.0461C3.08176 12.0201 3.0533 11.9726 2.9964 11.8777L2.0152 10.2415C1.9551 10.1413 1.92505 10.0911 1.92505 10.0358C1.92505 9.9804 1.9551 9.93029 2.0152 9.83006L2.98951 8.20528C3.04963 8.10502 3.07969 8.0549 3.07969 7.99951C3.07968 7.94413 3.04961 7.89401 2.98946 7.79377L2.01529 6.17011C1.95514 6.06987 1.92507 6.01975 1.92507 5.96437C1.92506 5.90899 1.95512 5.85886 2.01524 5.7586L2.9964 4.1224C3.0533 4.0275 3.08176 3.98005 3.12761 3.95408C3.17347 3.92811 3.2288 3.92811 3.33945 3.92811H5.32977C5.44036 3.92811 5.49566 3.92811 5.54151 3.90216C5.58735 3.87621 5.61581 3.82879 5.67273 3.73397L6.6619 2.08599C6.71881 1.99116 6.74727 1.94375 6.79312 1.9178C6.83896 1.89185 6.89426 1.89185 7.00486 1.89185H8.99529C9.10588 1.89185 9.16118 1.89185 9.20703 1.9178C9.25287 1.94375 9.28133 1.99116 9.33825 2.08599L10.3274 3.73397C10.3843 3.82879 10.4128 3.87621 10.4586 3.90216C10.5045 3.92811 10.5598 3.92811 10.6704 3.92811H12.6607C12.7713 3.92811 12.8267 3.92811 12.8725 3.95408C12.9184 3.98005 12.9468 4.0275 13.0037 4.1224L13.9849 5.7586C14.045 5.85886 14.0751 5.90899 14.0751 5.96437C14.0751 6.01975 14.045 6.06987 13.9849 6.17011L13.0107 7.79377Z',
509
+ stroke: 'currentColor',
510
+ strokeMiterlimit: 10,
511
+ }),
512
+ );
513
+ }
514
+
515
+ /**
516
+ * The host's own V-shaped chevron, copied from the Client artifact so the
517
+ * section matches without importing a Client package.
518
+ * @param props - `open` turns it from pointing down to pointing right.
519
+ * @returns the chevron element.
520
+ */
521
+ function ChevronGlyph({ open }) {
522
+ return h(
523
+ 'span',
524
+ { className: 'dshUS-arrow', 'data-open': open ? 'true' : 'false' },
525
+ h(
526
+ 'svg',
527
+ {
528
+ width: 14,
529
+ height: 14,
530
+ viewBox: '0 0 16 16',
531
+ fill: 'none',
532
+ xmlns: 'http://www.w3.org/2000/svg',
533
+ 'aria-hidden': 'true',
534
+ strokeWidth: 1,
535
+ },
536
+ h('path', {
537
+ d: 'M4 6L7.29289 9.29289C7.68342 9.68342 8.31658 9.68342 8.70711 9.29289L12 6',
538
+ stroke: 'currentColor',
539
+ }),
540
+ ),
541
+ );
542
+ }
543
+ /** No live status at all: what an older host that publishes no status feed looks like. */
544
+ const NO_STATUSES = new Map();
545
+ /** Stand-in for a host without the status hook, so the sections still render. */
546
+ const useNoSessionStatus = (selector) => selector(NO_STATUSES);
547
+
548
+ /**
549
+ * The pending-interaction kind the sidebar renders, or undefined for a kind
550
+ * this plugin does not know. Mirrors the host's own closed set, so a future
551
+ * interaction kind never paints a marker the sidebar would not paint.
552
+ * @param kind - the runtime's pending-interaction classification.
553
+ * @returns the dictionary suffix naming that interaction, if any.
554
+ */
555
+ function pendingStatus(kind) {
556
+ switch (kind) {
557
+ case 'approval':
558
+ return 'waitingApproval';
559
+ case 'plan-review':
560
+ return 'planReview';
561
+ case 'question':
562
+ return 'waitingAnswer';
563
+ default:
564
+ return undefined;
565
+ }
566
+ }
567
+
568
+ /**
569
+ * How many of one Session's direct subagents are running, counted exactly
570
+ * as the sidebar counts them: the parent's persisted catalog, each child's
571
+ * live status winning over its list summary.
572
+ * @param sessions - the Session-list snapshot.
573
+ * @param sessionId - the parent Session.
574
+ * @param statuses - the Session-status snapshot.
575
+ * @returns the running direct-child count.
576
+ */
577
+ function runningChildCount(sessions, sessionId, statuses) {
578
+ const catalog = sessions.projectionsBySession?.[sessionId]?.values?.subagentCatalog;
579
+ if (!Array.isArray(catalog)) return 0;
580
+ let count = 0;
581
+ for (const child of catalog) {
582
+ const running = statuses.get(child.id)?.running ?? sessions.byId[child.id]?.running;
583
+ if (running === true) count += 1;
584
+ }
585
+ return count;
586
+ }
587
+
588
+ /**
589
+ * One Session's primary status, in the sidebar's own precedence: a pending
590
+ * interaction first, then live activity (its own turn or a running child),
591
+ * then an unviewed completion. An idle Session has no status, which is why
592
+ * its row draws no marker at all.
593
+ * @param sessions - the Session-list snapshot.
594
+ * @param statuses - the Session-status snapshot.
595
+ * @param sessionId - the Session to classify.
596
+ * @returns the primary status, or undefined while idle.
597
+ */
598
+ function sessionStatus(sessions, statuses, sessionId) {
599
+ const status = statuses.get(sessionId);
600
+ const pending = pendingStatus(status?.pendingInteraction?.kind);
601
+ if (pending !== undefined) return { state: 'warning', label: pending };
602
+ const running = status?.running ?? sessions.byId[sessionId]?.running;
603
+ if (running === true) return { state: 'ongoing', label: 'running' };
604
+ const children = runningChildCount(sessions, sessionId, statuses);
605
+ if (children > 0) return { state: 'ongoing', label: 'subagents', count: children };
606
+ if (status?.completionUnread === true) return { state: 'done', label: 'completed' };
607
+ return undefined;
608
+ }
609
+
610
+ /** The readable text of one primary status, localized by the section. */
611
+ function statusText(status, t) {
612
+ if (status.label === 'subagents') {
613
+ return t(status.count === 1 ? 'status.subagents.one' : 'status.subagents.other', {
614
+ n: status.count,
615
+ });
616
+ }
617
+ return t(`status.${status.label}`);
618
+ }
619
+
620
+ /**
621
+ * The Session row's leading status marker: the host's spinning ring while
622
+ * the Session works, otherwise the host's small state dot — the same green
623
+ * completion and amber pending-interaction colours the sidebar paints. The
624
+ * marker is decorative; the row's own label carries the status in words.
625
+ * @param props - the primary status and its readable text.
626
+ * @returns the marker element.
627
+ */
628
+ function StatusMark({ status, label }) {
629
+ return h(
630
+ 'span',
631
+ { className: 'dshUS-status', title: label, 'aria-hidden': 'true' },
632
+ status.state === 'ongoing'
633
+ ? h(
634
+ 'svg',
635
+ {
636
+ className: 'dshUS-spinner',
637
+ 'data-state': 'ongoing',
638
+ width: 14,
639
+ height: 14,
640
+ viewBox: '0 0 24 24',
641
+ 'aria-hidden': 'true',
642
+ },
643
+ h(
644
+ 'g',
645
+ { className: 'dshUS-spinnerMotion' },
646
+ h('circle', { className: 'dshUS-spinnerTrack', cx: '12', cy: '12', r: '9.5' }),
647
+ h('circle', { className: 'dshUS-spinnerArc', cx: '12', cy: '12', r: '9.5' }),
648
+ ),
649
+ )
650
+ : h('span', { className: 'dshUS-dot', 'data-state': status.state }),
651
+ );
652
+ }
653
+
654
+ /** Normalize a Session activity stamp to epoch milliseconds; absent means oldest. */
655
+ function toMillis(value) {
656
+ if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
657
+ if (typeof value === 'string') {
658
+ const parsed = Date.parse(value);
659
+ return Number.isNaN(parsed) ? 0 : parsed;
660
+ }
661
+ return 0;
662
+ }
663
+
664
+ /** Normalize directory separators the way the sidebar compares Workspace paths. */
665
+ function folderPath(path) {
666
+ const slashed =
667
+ /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\') ? path.replaceAll('\\', '/') : path;
668
+ return slashed.replace(/\/+$/, '');
669
+ }
670
+
671
+ /**
672
+ * The nearest registered Workspace directory that contains `path`, or
673
+ * undefined when no other Workspace holds it. Mirrors the sidebar's own
674
+ * nesting rule: the longest containing registered path, compared
675
+ * case-sensitively and excluding the directory itself.
676
+ * @param path - one Workspace directory.
677
+ * @param paths - every registered Workspace directory.
678
+ * @returns the owning parent directory, if any.
679
+ */
680
+ function nearestAncestorPath(path, paths) {
681
+ const child = folderPath(path);
682
+ let owner;
683
+ let length = -1;
684
+ for (const candidate of paths) {
685
+ const root = folderPath(candidate);
686
+ if (root.length > length && child !== root && child.startsWith(`${root}/`)) {
687
+ owner = candidate;
688
+ length = root.length;
689
+ }
690
+ }
691
+ return owner;
692
+ }
693
+
694
+ /**
695
+ * The Workspace groups whose rows lead to `workspaceId` in Workspace Tree
696
+ * grouping: outermost ancestor first, the Workspace itself last. The empty
697
+ * key is the sidebar's "Ungrouped" bucket and has no ancestors. A flat
698
+ * grouped list needs only the Workspace itself, which is what the lookup
699
+ * returns when no other registered path contains it.
700
+ * @param workspaces - the sidebar's Workspace items, in list order.
701
+ * @param workspaceId - the Workspace that owns one pinned Session.
702
+ * @returns the group ids to open, outermost first.
703
+ */
704
+ function workspaceChain(workspaces, workspaceId) {
705
+ if (workspaceId === '') return [''];
706
+ const folders = workspaces.filter((workspace) => typeof workspace.path === 'string');
707
+ const paths = folders.map((workspace) => workspace.path);
708
+ const idByPath = new Map(folders.map((workspace) => [workspace.path, workspace.workspaceId]));
709
+ const chain = [workspaceId];
710
+ let workspace = folders.find((item) => item.workspaceId === workspaceId);
711
+ while (workspace !== undefined) {
712
+ const parentPath = nearestAncestorPath(workspace.path, paths);
713
+ if (parentPath === undefined) break;
714
+ const parentId = idByPath.get(parentPath);
715
+ if (parentId === undefined || chain.includes(parentId)) break;
716
+ chain.unshift(parentId);
717
+ workspace = folders.find((item) => item.workspaceId === parentId);
718
+ }
719
+ return chain;
720
+ }
721
+
722
+ /**
723
+ * Expand every collapsed Workspace group on the way to one Workspace, then
724
+ * open the Session. A plugin has no service handle on the sidebar's
725
+ * expansion state, so the plugin drives the host's own group toggle: a
726
+ * group row's click handler is the one the sidebar's header uses, and it
727
+ * both opens the group and saves that choice exactly as a user's click
728
+ * would. The Workspace's own row is always opened; an ancestor is opened
729
+ * only when its descendant row is missing, which is exactly the nested
730
+ * Workspace Tree case — a flat grouped list renders every Workspace, so no
731
+ * unrelated folder is unfolded there. Nothing here touches the selected-row
732
+ * highlight: that stays the sidebar's own `mainView` rendering.
733
+ * @param workspaces - the sidebar's Workspace items, in list order.
734
+ * @param workspaceId - the Workspace that owns the Session being opened.
735
+ */
736
+ function expandWorkspacePath(workspaces, workspaceId) {
737
+ // No known owner means the Workspace list has not been applied yet; the
738
+ // "Ungrouped" bucket would be a guess, so leave the tree alone.
739
+ if (workspaceId === undefined) return;
740
+ const chain = workspaceChain(workspaces, workspaceId);
741
+ if (chain.length === 0) return;
742
+ // Ids are opaque Host values, so rows are matched through `dataset` rather
743
+ // than through a selector assembled from one. No group row at all means
744
+ // the list is not showing a grouped tree (a folded sidebar, the flat
745
+ // "Last updated" view, or search results), so there is nothing to unfold.
746
+ if (workspaceRows().length === 0) return;
747
+ // A folded ancestor renders no child rows, so the walk runs outermost
748
+ // first. React commits a discrete click before `dispatchEvent` returns,
749
+ // which makes the children's rows visible to the next lookup; should a
750
+ // host version commit them later, the walk repeats next frame, and each
751
+ // key is clicked at most once so a repeat never folds a group it opened.
752
+ let remaining = chain.length + 1;
753
+ const opened = new Set();
754
+ const pass = () => {
755
+ for (const [index, key] of chain.entries()) {
756
+ if (opened.has(key)) continue;
757
+ const row = findWorkspaceRow(key);
758
+ if (row === undefined || row.getAttribute('aria-expanded') !== 'false') continue;
759
+ const last = index === chain.length - 1;
760
+ const hidesTarget =
761
+ !last && chain.slice(index + 1).some((next) => findWorkspaceRow(next) === undefined);
762
+ if (!last && !hidesTarget) continue;
763
+ opened.add(key);
764
+ row.click();
765
+ }
766
+ const target = chain[chain.length - 1];
767
+ const targetRow = findWorkspaceRow(target);
768
+ const done = targetRow !== undefined && targetRow.getAttribute('aria-expanded') !== 'false';
769
+ if (done || remaining <= 0) return;
770
+ remaining -= 1;
771
+ requestAnimationFrame(pass);
772
+ };
773
+ pass();
774
+ }
775
+
776
+ /** The data-row-key value of one Workspace group row. */
777
+ function workspaceRowKey(workspaceId) {
778
+ return `workspace:${workspaceId}`;
779
+ }
780
+
781
+ /** Every Workspace group row the sidebar currently renders. */
782
+ function workspaceRows() {
783
+ return document.querySelectorAll(
784
+ '[data-slot="sidebar.workspaces"] [data-row-key^="workspace:"]',
785
+ );
786
+ }
787
+
788
+ /**
789
+ * The rendered Workspace group row for one Workspace id, if the sidebar is
790
+ * showing a grouped tree.
791
+ * @param workspaceId - the Workspace whose group row is wanted.
792
+ * @returns the row, or undefined when it is not rendered.
793
+ */
794
+ function findWorkspaceRow(workspaceId) {
795
+ for (const row of workspaceRows()) {
796
+ if (row instanceof HTMLElement && row.dataset.rowKey === workspaceRowKey(workspaceId)) {
797
+ return row;
798
+ }
799
+ }
800
+ return undefined;
801
+ }
802
+
803
+ /**
804
+ * Keep a collapsed Workspace group collapsed when the reveal preference is
805
+ * off. The sidebar expands the group that owns the current Session whenever
806
+ * that group has no explicit expansion choice on record, so without this a
807
+ * group the user has never toggled would open on its own and defeat the
808
+ * preference. The group row's click handler is the sidebar's own toggle, so
809
+ * clicking it twice restores the visible state while leaving that explicit
810
+ * record; both discrete clicks flush inside this task, so no frame paints
811
+ * the intermediate state. An already-expanded group needs nothing: its
812
+ * choice is either explicit or already the state the sidebar would keep.
813
+ * @param workspaceId - the Workspace that owns the Session being opened.
814
+ */
815
+ function pinCollapsedWorkspace(workspaceId) {
816
+ if (workspaceId === undefined) return;
817
+ const row = findWorkspaceRow(workspaceId);
818
+ if (row === undefined || row.getAttribute('aria-expanded') !== 'false') return;
819
+ row.click();
820
+ row.click();
821
+ // Should a host version batch the pair instead of flushing each discrete
822
+ // click, the row would end expanded; restore it on the next frame.
823
+ requestAnimationFrame(() => {
824
+ const current = findWorkspaceRow(workspaceId);
825
+ if (current?.getAttribute('aria-expanded') === 'true') current.click();
826
+ });
827
+ }
828
+
829
+ /** Compact relative time of one row's last activity. */
830
+ function relativeTime(value, now, t) {
831
+ const then = toMillis(value);
832
+ if (then === 0) return '';
833
+ const diff = Math.max(0, now - then);
834
+ const minutes = Math.floor(diff / 60000);
835
+ if (minutes < 1) return t('time.now');
836
+ if (minutes < 60) return t('time.minutes', { n: minutes });
837
+ const hours = Math.floor(minutes / 60);
838
+ if (hours < 24) return t('time.hours', { n: hours });
839
+ const days = Math.floor(hours / 24);
840
+ if (days < 30) return t('time.days', { n: days });
841
+ const months = Math.floor(days / 30);
842
+ if (months < 12) return t('time.months', { n: months });
843
+ return t('time.years', { n: Math.floor(months / 12) });
844
+ }
845
+
846
+ /** How many recent rows one click reveals, and how many show at rest. */
847
+ const RECENT_PAGE = 5;
848
+ /** localStorage key holding this plugin's browser-local preferences. */
849
+ const PREFERENCES_KEY = 'dsh-up-session/preferences.v1';
850
+
851
+ /**
852
+ * The plugin's preferences, read from browser storage. A missing, malformed,
853
+ * or unreadable record falls back to the defaults; in particular the
854
+ * workspace-reveal behavior is on, which is what every earlier version did.
855
+ * @returns the current preference values.
856
+ */
857
+ function readPreferences() {
858
+ const fallback = { expandWorkspaceOnOpen: true, showPinned: true, showRecent: true };
859
+ try {
860
+ const raw = localStorage.getItem(PREFERENCES_KEY);
861
+ if (raw === null) return fallback;
862
+ const parsed = JSON.parse(raw);
863
+ const preferences = { ...fallback };
864
+ for (const key of Object.keys(fallback)) {
865
+ if (typeof parsed?.[key] === 'boolean') preferences[key] = parsed[key];
866
+ }
867
+ return preferences;
868
+ } catch (_reason) {
869
+ return fallback;
870
+ }
871
+ }
872
+
873
+ /**
874
+ * Persist the whole preference record. A refused write only costs the
875
+ * persisted choice, which the caller has already applied in memory.
876
+ * @param preferences - the values to store.
877
+ */
878
+ function writePreferences(preferences) {
879
+ try {
880
+ localStorage.setItem(PREFERENCES_KEY, JSON.stringify(preferences));
881
+ } catch (reason) {
882
+ console.warn('session preference not saved:', reason);
883
+ }
884
+ }
885
+
886
+ /**
887
+ * The recent list's window for one page limit. A limit below one page still
888
+ * shows one page, so the list never shrinks under its rest size.
889
+ * @param rowCount - how many recent Sessions there are.
890
+ * @param limit - how many the list is currently allowed to show.
891
+ * @returns the visible row count, whether every Session is on screen, and
892
+ * the limit one click installs next.
893
+ */
894
+ function recentWindow(rowCount, limit) {
895
+ const shown = Math.max(RECENT_PAGE, limit);
896
+ const allShown = shown >= rowCount;
897
+ return {
898
+ visible: Math.min(shown, rowCount),
899
+ allShown,
900
+ next: allShown ? RECENT_PAGE : shown + RECENT_PAGE,
901
+ };
902
+ }
903
+
904
+ /**
905
+ * One Session-list summary as one row of either section. Every row carries
906
+ * the same shape; only which Sessions become rows and which trailing
907
+ * actions they offer differ between the sections.
908
+ * @param summary - the Session-list summary.
909
+ * @param owner - the Workspace that accounts for it, if one does.
910
+ * @param loaded - whether the Workspace list has loaded at all.
911
+ * @param untitled - the label an untitled Session shows.
912
+ * @param statuses - the Session-status snapshot.
913
+ * @param sessions - the Session-list snapshot, read for a Session's own and child activity.
914
+ * @returns the row.
915
+ */
916
+ function sessionRow(summary, owner, loaded, untitled, statuses, sessions) {
917
+ return {
918
+ id: summary.id,
919
+ title: summary.displayTitle || untitled,
920
+ workspace: owner?.title ?? '',
921
+ // A known owner names its Workspace; once the list has loaded, a Session
922
+ // no Workspace accounts for sits in the "Ungrouped" bucket (`''`).
923
+ // Before that, leave it unknown so nothing is guessed.
924
+ workspaceId: owner?.workspaceId ?? (loaded ? '' : undefined),
925
+ updatedAt: toMillis(summary.updatedAt),
926
+ // Undefined while the Session is idle, and for the provisional New
927
+ // Session blank: the row then draws no leading marker at all, which is
928
+ // also how the sidebar renders both of those rows.
929
+ status: summary.blank ? undefined : sessionStatus(sessions, statuses, summary.id),
930
+ };
931
+ }
932
+
933
+ /**
934
+ * The pinned section's rows: every pinned Session that is not archived,
935
+ * newest activity first. Ties keep the registry's order (most recently
936
+ * pinned first).
937
+ * @param pinnedSessionIds - the Host's pin set.
938
+ * @param archivedSessionIds - the Host's archive set.
939
+ * @param sessions - the Session-list snapshot.
940
+ * @param statuses - the Session-status snapshot.
941
+ * @param owners - Session id to the Workspace that accounts for it.
942
+ * @param loaded - whether the Workspace list has loaded at all.
943
+ * @param untitled - the label an untitled Session shows.
944
+ * @returns the rows in display order.
945
+ */
946
+ function pinnedRows(
947
+ pinnedSessionIds,
948
+ archivedSessionIds,
949
+ sessions,
950
+ statuses,
951
+ owners,
952
+ loaded,
953
+ untitled,
954
+ ) {
955
+ const archived = new Set(archivedSessionIds);
956
+ const rows = [];
957
+ for (const id of pinnedSessionIds) {
958
+ if (archived.has(id)) continue;
959
+ const summary = sessions.byId[id];
960
+ if (summary === undefined) continue;
961
+ rows.push(sessionRow(summary, owners.get(id), loaded, untitled, statuses, sessions));
962
+ }
963
+ // `updatedAt` is the Session-list activity stamp the Host advances on each
964
+ // user-authored durable message.
965
+ rows.sort((left, right) => right.updatedAt - left.updatedAt);
966
+ return rows;
967
+ }
968
+
969
+ /**
970
+ * The recent section's rows: every ordinary Session that is neither pinned
971
+ * nor archived, newest activity first. The pinned section already holds the
972
+ * pinned ones, so the two sections never show one conversation twice; the
973
+ * provisional New Session blank and subagent children stay out exactly as
974
+ * the sidebar hides them.
975
+ * @param sessions - the Session-list snapshot.
976
+ * @param archivedSessionIds - the Host's archive set.
977
+ * @param pinnedSessionIds - the Host's pin set.
978
+ * @param statuses - the Session-status snapshot.
979
+ * @param owners - Session id to the Workspace that accounts for it.
980
+ * @param loaded - whether the Workspace list has loaded at all.
981
+ * @param untitled - the label an untitled Session shows.
982
+ * @returns the rows in display order.
983
+ */
984
+ function recentRows(
985
+ sessions,
986
+ archivedSessionIds,
987
+ pinnedSessionIds,
988
+ statuses,
989
+ owners,
990
+ loaded,
991
+ untitled,
992
+ ) {
993
+ const archived = new Set(archivedSessionIds);
994
+ const pinned = new Set(pinnedSessionIds);
995
+ const rows = [];
996
+ for (const id of sessions.ids) {
997
+ const summary = sessions.byId[id];
998
+ if (summary === undefined) continue;
999
+ if (summary.blank || summary.origin === 'subagent') continue;
1000
+ if (archived.has(id) || pinned.has(id)) continue;
1001
+ rows.push(sessionRow(summary, owners.get(id), loaded, untitled, statuses, sessions));
1002
+ }
1003
+ // Equal stamps break by id, the way the sidebar's own recency order does,
1004
+ // so the list never jitters between renders.
1005
+ rows.sort((left, right) => right.updatedAt - left.updatedAt || (left.id < right.id ? -1 : 1));
1006
+ return rows;
1007
+ }
1008
+
1009
+ /**
1010
+ * The root of the plugin's sidebar surface: the pinned section, the recent
1011
+ * section, and the one archive confirmation both sections can raise. It is
1012
+ * rendered through a portal into the host node the plugin keeps just above
1013
+ * the workspace slot's anchor.
1014
+ * @param props - composed slot props plus the injected host and actions.
1015
+ * @returns the portal, or null when there is nowhere to portal into.
1016
+ */
1017
+ function SidebarSections(props) {
1018
+ const {
1019
+ host,
1020
+ open: openSession,
1021
+ pin,
1022
+ unpin,
1023
+ archive,
1024
+ stopAndArchive,
1025
+ t,
1026
+ useWorkspaces,
1027
+ useSessions,
1028
+ useSessionStatus,
1029
+ usePanelInfo,
1030
+ settingsEvents,
1031
+ } = props;
1032
+ const pinnedSessionIds = useWorkspaces((state) => state.pinnedSessionIds);
1033
+ const archivedSessionIds = useWorkspaces((state) => state.archivedSessionIds);
1034
+ const workspaces = useWorkspaces((state) => state.items);
1035
+ const sessions = useSessions((state) => state);
1036
+ // The sidebar's own live-status feed: running turns, running direct
1037
+ // children, pending interactions, and unviewed completions, keyed by
1038
+ // Session id. Reading the same source is what keeps these rows in step
1039
+ // with the workspace list rather than on a separate timer. A host that
1040
+ // predates the feed still renders: the running flag then comes from the
1041
+ // Session summary alone, and the other two states are simply never seen.
1042
+ const readStatuses = useSessionStatus ?? useNoSessionStatus;
1043
+ const statuses = readStatuses((state) => state);
1044
+ // The sidebar's own definition of "current session": the one retained by
1045
+ // the main view, and only while the Conversation (not a panel) is shown.
1046
+ const panelActive = usePanelInfo((info) => info.activePanelId !== null);
1047
+ const currentId = panelActive
1048
+ ? undefined
1049
+ : Object.values(sessions.byId).find((session) => (session.retainedBy?.mainView ?? 0) > 0)?.id;
1050
+ // The archive confirm request: `null` until the Host refuses an archive
1051
+ // because the Session still has running work. It lives at the root because
1052
+ // the row that raised it may unmount (a sibling archive changes the pinned
1053
+ // or recent set) while the question is still open.
1054
+ const [archiveRequest, setArchiveRequest] = React.useState(null);
1055
+ // The plugin's own settings popup and the preferences it edits. Both
1056
+ // live at the root so either section header can open the same popup and
1057
+ // every row reads the same value; the choice is persisted on each click.
1058
+ const [settingsOpen, setSettingsOpen] = React.useState(false);
1059
+ React.useEffect(() => {
1060
+ const openSettings = () => setSettingsOpen(true);
1061
+ settingsEvents.addEventListener('open', openSettings);
1062
+ return () => settingsEvents.removeEventListener('open', openSettings);
1063
+ }, [settingsEvents]);
1064
+ const [preferences, setPreferences] = React.useState(readPreferences);
1065
+ const { expandWorkspaceOnOpen, showPinned, showRecent } = preferences;
1066
+ React.useEffect(() => writePreferences(preferences), [preferences]);
1067
+ const changePreference = (key, value) => {
1068
+ setPreferences((previous) => {
1069
+ const next = { ...previous, [key]: value };
1070
+ return next;
1071
+ });
1072
+ };
1073
+ const raiseArchive = (row) => {
1074
+ archive(row.id)
1075
+ .then((result) => {
1076
+ if (!result.refused) return;
1077
+ setArchiveRequest({
1078
+ sessionId: row.id,
1079
+ title: row.title,
1080
+ activity: result.activity,
1081
+ });
1082
+ })
1083
+ .catch((reason) => {
1084
+ console.warn('session archive rejected:', reason);
1085
+ });
1086
+ };
1087
+ // Opening a row first opens the Workspace that owns it, so the Session
1088
+ // the sidebar marks as current is actually on screen. The open call is
1089
+ // unchanged, so the sections keep their own `dshUS-current` highlight and
1090
+ // the sidebar keeps its native selected row. The reveal step is the one
1091
+ // preference: with it off, the click only opens the Session and pins the
1092
+ // group's current collapsed state so the sidebar's own current-Session
1093
+ // expansion cannot unfold it either.
1094
+ const openRow = (row) => {
1095
+ if (expandWorkspaceOnOpen) expandWorkspacePath(workspaces, row.workspaceId);
1096
+ else pinCollapsedWorkspace(row.workspaceId);
1097
+ openSession(row.id);
1098
+ };
1099
+
1100
+ const owners = React.useMemo(() => {
1101
+ const index = new Map();
1102
+ for (const workspace of workspaces) {
1103
+ for (const id of workspace.sessionIds) index.set(id, workspace);
1104
+ }
1105
+ return index;
1106
+ }, [workspaces]);
1107
+
1108
+ const loaded = workspaces.length > 0;
1109
+ const untitled = t('untitled');
1110
+ const pinned = React.useMemo(
1111
+ () =>
1112
+ pinnedRows(
1113
+ pinnedSessionIds,
1114
+ archivedSessionIds,
1115
+ sessions,
1116
+ statuses,
1117
+ owners,
1118
+ loaded,
1119
+ untitled,
1120
+ ),
1121
+ [pinnedSessionIds, archivedSessionIds, sessions, statuses, owners, loaded, untitled],
1122
+ );
1123
+ const recent = React.useMemo(
1124
+ () =>
1125
+ recentRows(
1126
+ sessions,
1127
+ archivedSessionIds,
1128
+ pinnedSessionIds,
1129
+ statuses,
1130
+ owners,
1131
+ loaded,
1132
+ untitled,
1133
+ ),
1134
+ [sessions, archivedSessionIds, pinnedSessionIds, statuses, owners, loaded, untitled],
1135
+ );
1136
+
1137
+ const dialog = h(ArchiveConfirmDialog, {
1138
+ request: archiveRequest,
1139
+ stopAndArchive,
1140
+ onSettle: () => setArchiveRequest(null),
1141
+ t,
1142
+ });
1143
+ const settingsDialog =
1144
+ settingsOpen &&
1145
+ h(SettingsDialog, {
1146
+ preferences,
1147
+ onChangePreference: changePreference,
1148
+ onClose: () => setSettingsOpen(false),
1149
+ t,
1150
+ });
1151
+
1152
+ const shared = {
1153
+ currentId,
1154
+ onOpen: openRow,
1155
+ onArchive: raiseArchive,
1156
+ onOpenSettings: () => setSettingsOpen(true),
1157
+ settingsOpen,
1158
+ t,
1159
+ };
1160
+
1161
+ const body = h(
1162
+ React.Fragment,
1163
+ null,
1164
+ showPinned && h(PinnedSection, {
1165
+ ...shared,
1166
+ rows: pinned,
1167
+ onUnpin: (row) => unpin(row.id),
1168
+ }),
1169
+ showRecent && h(RecentSection, {
1170
+ ...shared,
1171
+ rows: recent,
1172
+ onPin: (row) => pin(row.id),
1173
+ }),
1174
+ settingsDialog,
1175
+ dialog,
1176
+ );
1177
+
1178
+ return host === null ? null : createPortal(body, host);
1179
+ }
1180
+
1181
+ /**
1182
+ * One collapsible section header, copied from the host's "Workspaces"
1183
+ * header: the row itself is the toggle, the chevron states the direction,
1184
+ * and the settings entry appears at the row's right edge on hover. The entry
1185
+ * is a sibling of the toggle rather than a child, because a button cannot
1186
+ * nest inside another button.
1187
+ * @param props - the visible label, the expanded state, the toggle, the labels naming the action, and the settings entry.
1188
+ * @returns the header row.
1189
+ */
1190
+ function SectionHeader({
1191
+ label,
1192
+ expanded,
1193
+ onToggle,
1194
+ expandLabel,
1195
+ collapseLabel,
1196
+ onOpenSettings,
1197
+ settingsOpen,
1198
+ settingsLabel,
1199
+ }) {
1200
+ return h(
1201
+ 'div',
1202
+ { className: 'dshUS-headerRow' },
1203
+ h(
1204
+ 'button',
1205
+ {
1206
+ type: 'button',
1207
+ className: 'dshUS-header',
1208
+ 'aria-expanded': expanded,
1209
+ 'aria-label': expanded ? collapseLabel : expandLabel,
1210
+ title: expanded ? collapseLabel : expandLabel,
1211
+ onClick: onToggle,
1212
+ },
1213
+ h('span', { className: 'dshUS-label' }, label),
1214
+ h(ChevronGlyph, { open: expanded }),
1215
+ ),
1216
+ h(
1217
+ 'button',
1218
+ {
1219
+ type: 'button',
1220
+ className: 'dshUS-settingsButton',
1221
+ 'aria-label': settingsLabel,
1222
+ 'aria-haspopup': 'dialog',
1223
+ 'aria-expanded': settingsOpen,
1224
+ title: settingsLabel,
1225
+ onClick: onOpenSettings,
1226
+ },
1227
+ h(SettingsGlyph, { size: 16 }),
1228
+ ),
1229
+ );
1230
+ }
1231
+
1232
+ /** The placeholder row a section shows while it holds no Session. */
1233
+ function EmptyRow({ label }) {
1234
+ return h('div', { className: 'dshUS-empty' }, h('span', { className: 'dshUS-emptyLabel' }, label));
1235
+ }
1236
+
1237
+ /**
1238
+ * One Session row: an optional leading status marker, the title, the
1239
+ * relative time, and the trailing actions that replace the time on hover or
1240
+ * keyboard focus. A pinned row keeps the host's own pin marker where the
1241
+ * time would end, so a pinned row looks pinned at rest exactly as the
1242
+ * sidebar's rows do.
1243
+ * @param props - the row, the current Session id, the open handler, the trailing buttons, the pin marker, and the translator.
1244
+ * @returns the row element.
1245
+ */
1246
+ function SessionRow({ row, currentId, onOpen, actions, pinned, t }) {
1247
+ const now = Date.now();
1248
+ // A live status is the row's own leading cell, exactly as the sidebar
1249
+ // draws it; an idle row draws none, so quiet rows keep the section's left
1250
+ // edge instead of the sidebar's deeper indent.
1251
+ const statusLabel = row.status === undefined ? undefined : statusText(row.status, t);
1252
+ return h(
1253
+ 'div',
1254
+ {
1255
+ className: row.id === currentId ? 'dshUS-row dshUS-current' : 'dshUS-row',
1256
+ role: 'button',
1257
+ tabIndex: 0,
1258
+ 'aria-current': row.id === currentId ? 'true' : undefined,
1259
+ title: row.workspace === '' ? row.title : `${row.title} · ${row.workspace}`,
1260
+ 'aria-label':
1261
+ statusLabel === undefined
1262
+ ? t('openNamed', { title: row.title })
1263
+ : t('openNamedStatus', { title: row.title, status: statusLabel }),
1264
+ onClick: () => onOpen(row),
1265
+ onKeyDown: (event) => {
1266
+ if (event.key !== 'Enter' && event.key !== ' ') return;
1267
+ event.preventDefault();
1268
+ onOpen(row);
1269
+ },
1270
+ },
1271
+ row.status !== undefined &&
1272
+ h(StatusMark, { status: row.status, label: statusLabel }),
1273
+ h('span', { className: 'dshUS-title' }, row.title),
1274
+ h('span', { className: 'dshUS-time' }, relativeTime(row.updatedAt, now, t)),
1275
+ pinned === true &&
1276
+ h(
1277
+ 'span',
1278
+ {
1279
+ className: 'dshUS-pinMark',
1280
+ role: 'img',
1281
+ 'aria-label': t('pinned'),
1282
+ title: t('pinned'),
1283
+ },
1284
+ h(PinGlyph, { size: 14 }),
1285
+ ),
1286
+ // The trailing group swallows clicks, so a button press never also
1287
+ // opens the row (the host's rows do the same).
1288
+ h(
1289
+ 'span',
1290
+ {
1291
+ className: 'dshUS-actions',
1292
+ onClick: (event) => {
1293
+ event.stopPropagation();
1294
+ },
1295
+ onKeyDown: (event) => {
1296
+ event.stopPropagation();
1297
+ },
1298
+ },
1299
+ actions.map((action) =>
1300
+ h(
1301
+ 'button',
1302
+ {
1303
+ key: action.key,
1304
+ type: 'button',
1305
+ className: 'dshUS-action',
1306
+ 'aria-label': action.label,
1307
+ title: action.title,
1308
+ onClick: action.onClick,
1309
+ },
1310
+ action.icon,
1311
+ ),
1312
+ ),
1313
+ ),
1314
+ );
1315
+ }
1316
+
1317
+ /** The archive button every row owns, built from one row's identity. */
1318
+ function archiveAction(row, onArchive, t) {
1319
+ return {
1320
+ key: 'archive',
1321
+ label: t('archiveNamed', { title: row.title }),
1322
+ title: t('archive'),
1323
+ icon: h(ArchiveGlyph, { size: 14 }),
1324
+ onClick: () => onArchive(row),
1325
+ };
1326
+ }
1327
+
1328
+ /**
1329
+ * The pinned section: every pinned Session across all Workspaces, newest
1330
+ * activity first. Its trailing action unpins, which is the one state the
1331
+ * recent section never has to offer.
1332
+ * @returns the section element.
1333
+ */
1334
+ function PinnedSection({
1335
+ rows,
1336
+ currentId,
1337
+ onOpen,
1338
+ onArchive,
1339
+ onUnpin,
1340
+ onOpenSettings,
1341
+ settingsOpen,
1342
+ t,
1343
+ }) {
1344
+ // Expanded by default; the choice lasts for this mount. Nothing is
1345
+ // persisted, so a reload returns to the default and no stale collapse can
1346
+ // hide the section without the user knowing why.
1347
+ const [expanded, setExpanded] = React.useState(true);
1348
+ const toggleExpanded = () => setExpanded((value) => !value);
1349
+ return h(
1350
+ 'section',
1351
+ { className: 'dshUS-root', 'aria-label': t('section') },
1352
+ h(SectionHeader, {
1353
+ label: t('section'),
1354
+ expanded,
1355
+ onToggle: toggleExpanded,
1356
+ expandLabel: t('expand'),
1357
+ collapseLabel: t('collapse'),
1358
+ onOpenSettings,
1359
+ settingsOpen,
1360
+ settingsLabel: t('settings'),
1361
+ }),
1362
+ expanded &&
1363
+ (rows.length === 0
1364
+ ? h(EmptyRow, { label: t('empty') })
1365
+ : rows.map((row) =>
1366
+ h(SessionRow, {
1367
+ key: row.id,
1368
+ row,
1369
+ currentId,
1370
+ onOpen,
1371
+ t,
1372
+ // Every row here is pinned, so every row wears the marker.
1373
+ pinned: true,
1374
+ actions: [
1375
+ archiveAction(row, onArchive, t),
1376
+ {
1377
+ key: 'unpin',
1378
+ label: t('unpinNamed', { title: row.title }),
1379
+ title: t('unpin'),
1380
+ icon: h(PinGlyph, { size: 14 }),
1381
+ onClick: () => onUnpin(row),
1382
+ },
1383
+ ],
1384
+ }),
1385
+ )),
1386
+ );
1387
+ }
1388
+
1389
+ /**
1390
+ * The recent section: ordinary Sessions that are not pinned, newest
1391
+ * activity first, revealed five at a time. The header collapses the whole
1392
+ * section; the footer button pages the list, and once every Session is on
1393
+ * screen it collapses the list back to its first five.
1394
+ * @returns the section element.
1395
+ */
1396
+ function RecentSection({
1397
+ rows,
1398
+ currentId,
1399
+ onOpen,
1400
+ onArchive,
1401
+ onPin,
1402
+ onOpenSettings,
1403
+ settingsOpen,
1404
+ t,
1405
+ }) {
1406
+ const [expanded, setExpanded] = React.useState(true);
1407
+ // How many rows the list is allowed to show; the button below the list
1408
+ // moves it by one page, and no change to the Session set resets it, so a
1409
+ // new message elsewhere never folds a list the user opened.
1410
+ const [limit, setLimit] = React.useState(RECENT_PAGE);
1411
+ const toggleExpanded = () => setExpanded((value) => !value);
1412
+ const page = recentWindow(rows.length, limit);
1413
+ const visible = rows.slice(0, page.visible);
1414
+ const pager =
1415
+ rows.length > RECENT_PAGE
1416
+ ? h(
1417
+ 'button',
1418
+ {
1419
+ type: 'button',
1420
+ className: 'dshUS-more',
1421
+ 'aria-expanded': page.allShown,
1422
+ title: page.allShown ? t('showLess') : t('showMore'),
1423
+ onClick: () => setLimit(page.next),
1424
+ },
1425
+ page.allShown ? t('showLess') : t('showMore'),
1426
+ )
1427
+ : null;
1428
+ return h(
1429
+ 'section',
1430
+ { className: 'dshUS-root', 'aria-label': t('recent') },
1431
+ h(SectionHeader, {
1432
+ label: t('recent'),
1433
+ expanded,
1434
+ onToggle: toggleExpanded,
1435
+ expandLabel: t('recentExpand'),
1436
+ collapseLabel: t('recentCollapse'),
1437
+ onOpenSettings,
1438
+ settingsOpen,
1439
+ settingsLabel: t('settings'),
1440
+ }),
1441
+ expanded &&
1442
+ (rows.length === 0
1443
+ ? h(EmptyRow, { label: t('recentEmpty') })
1444
+ : visible.map((row) =>
1445
+ h(SessionRow, {
1446
+ key: row.id,
1447
+ row,
1448
+ currentId,
1449
+ onOpen,
1450
+ t,
1451
+ // No row here is pinned, so no row wears the marker and the
1452
+ // time is the row's trailing cell, flush with its edge.
1453
+ pinned: false,
1454
+ actions: [
1455
+ archiveAction(row, onArchive, t),
1456
+ {
1457
+ key: 'pin',
1458
+ label: t('pinNamed', { title: row.title }),
1459
+ title: t('pin'),
1460
+ icon: h(PinGlyph, { size: 14 }),
1461
+ onClick: () => onPin(row),
1462
+ },
1463
+ ],
1464
+ }),
1465
+ )),
1466
+ expanded && pager,
1467
+ );
1468
+ }
1469
+
1470
+ /**
1471
+ * One line of the confirmation list: the family's count and the items it
1472
+ * names. A family this dictionary does not know falls through to the
1473
+ * generic line, because the Host merges provider-supplied kinds.
1474
+ * @param entry - one activity family with its items.
1475
+ * @param t - the plugin's translator.
1476
+ * @returns the list item text.
1477
+ */
1478
+ function activityLine(entry, t) {
1479
+ const items = entry.items ?? [];
1480
+ const n = items.length;
1481
+ const names = items.map((item) => item.label ?? item.id).join(t('archiveConfirm.listSeparator'));
1482
+ const plural = n === 1 ? 'one' : 'other';
1483
+ switch (entry.kind) {
1484
+ case 'turn':
1485
+ return t('archiveConfirm.turn');
1486
+ case 'subagent':
1487
+ return t(`archiveConfirm.subagents.${plural}`, { n, names });
1488
+ case 'job':
1489
+ return t(`archiveConfirm.jobs.${plural}`, { n, names });
1490
+ case 'schedule':
1491
+ return t(`archiveConfirm.schedules.${plural}`, { n, names });
1492
+ default:
1493
+ return t(`archiveConfirm.other.${plural}`, { kind: entry.kind, n });
1494
+ }
1495
+ }
1496
+
1497
+ /** Entry on this bundle's native Plugins detail page, sharing the sidebar popup. */
1498
+ function PluginSettingsEntry({ t, onOpenSettings }) {
1499
+ return h(
1500
+ 'div',
1501
+ { className: 'dshUS-settingsRow' },
1502
+ h(
1503
+ 'div',
1504
+ { className: 'dshUS-settingsCopy' },
1505
+ h('div', { className: 'dshUS-settingsRowTitle' }, t('settingsTitle')),
1506
+ h('div', { className: 'dshUS-settingsRowDesc' }, t('settingsEntryDesc')),
1507
+ ),
1508
+ h(
1509
+ 'button',
1510
+ { type: 'button', className: 'dshUS-button dshUS-buttonOutline', onClick: onOpenSettings },
1511
+ t('settingsOpen'),
1512
+ ),
1513
+ );
1514
+ }
1515
+
1516
+ /**
1517
+ * The plugin's settings popup, built to read like the host's settings panel:
1518
+ * a centered layer-2 panel with a 54px header and preference rows whose
1519
+ * switch is the host's own 36x20 control. It portals to the body because the
1520
+ * sidebar column clips its contents, and it owns no saved state: the switch
1521
+ * writes through immediately, so closing the popup never discards a choice.
1522
+ * @param props - the preference value, its writer, settlement, and the translator.
1523
+ * @returns the modal layer.
1524
+ */
1525
+ function SettingsDialog({ preferences, onChangePreference, onClose, t }) {
1526
+ const titleId = React.useId();
1527
+ const descriptionId = React.useId();
1528
+ const panel = React.useRef(null);
1529
+ const settings = [
1530
+ { key: 'showPinned', label: 'settingsShowPinned', description: 'settingsShowPinnedDesc' },
1531
+ { key: 'showRecent', label: 'settingsShowRecent', description: 'settingsShowRecentDesc' },
1532
+ { key: 'expandWorkspaceOnOpen', label: 'settingsExpandWorkspace',
1533
+ description: 'settingsExpandWorkspaceDesc' },
1534
+ ];
1535
+ // Focus moves into the panel on open (the host's Modal does the same), and
1536
+ // the effect puts it back on the section's settings entry on close. That
1537
+ // entry hides when the header is not hovered and a hidden element cannot
1538
+ // take focus, so the fallback is the always-visible section toggle beside
1539
+ // it: keyboard users then tab once more to the revealed gear.
1540
+ React.useEffect(() => {
1541
+ const previous = document.activeElement;
1542
+ panel.current?.focus();
1543
+ return () => {
1544
+ if (!(previous instanceof HTMLElement)) return;
1545
+ const target =
1546
+ getComputedStyle(previous).visibility === 'hidden'
1547
+ ? previous.previousElementSibling
1548
+ : previous;
1549
+ if (target instanceof HTMLElement) target.focus();
1550
+ };
1551
+ }, []);
1552
+ // Escape closes the popup while it is the top layer. The listener reads
1553
+ // the latest close handler through the effect's dependency, so a re-render
1554
+ // never leaves a stale one attached.
1555
+ React.useEffect(() => {
1556
+ const onKeyDown = (event) => {
1557
+ if (event.key !== 'Escape') return;
1558
+ event.preventDefault();
1559
+ event.stopPropagation();
1560
+ onClose();
1561
+ };
1562
+ document.addEventListener('keydown', onKeyDown, true);
1563
+ return () => {
1564
+ document.removeEventListener('keydown', onKeyDown, true);
1565
+ };
1566
+ }, [onClose]);
1567
+ return createPortal(
1568
+ h(
1569
+ 'div',
1570
+ { className: 'dshUS-settingsRoot', role: 'presentation' },
1571
+ h('div', { className: 'dshUS-settingsMask', 'aria-hidden': 'true', onClick: onClose }),
1572
+ h(
1573
+ 'div',
1574
+ {
1575
+ ref: panel,
1576
+ tabIndex: -1,
1577
+ className: 'dshUS-settingsPanel',
1578
+ role: 'dialog',
1579
+ 'aria-modal': 'true',
1580
+ 'aria-labelledby': titleId,
1581
+ },
1582
+ h(
1583
+ 'div',
1584
+ { className: 'dshUS-settingsHeader' },
1585
+ h('h2', { className: 'dshUS-settingsTitle', id: titleId }, t('settingsTitle')),
1586
+ h(
1587
+ 'button',
1588
+ {
1589
+ type: 'button',
1590
+ className: 'dshUS-dialogClose',
1591
+ 'aria-label': t('settingsClose'),
1592
+ title: t('settingsClose'),
1593
+ onClick: onClose,
1594
+ },
1595
+ h(CloseGlyph, { size: 14 }),
1596
+ ),
1597
+ ),
1598
+ h(
1599
+ 'div',
1600
+ { className: 'dshUS-settingsBody' },
1601
+ ...settings.map((setting) => h(
1602
+ 'div',
1603
+ { className: 'dshUS-settingsRow', key: setting.key },
1604
+ h(
1605
+ 'div',
1606
+ { className: 'dshUS-settingsCopy' },
1607
+ h('div', { className: 'dshUS-settingsRowTitle' }, t(setting.label)),
1608
+ h(
1609
+ 'div',
1610
+ { className: 'dshUS-settingsRowDesc', id: descriptionId + setting.key },
1611
+ t(setting.description),
1612
+ ),
1613
+ ),
1614
+ h(
1615
+ 'button',
1616
+ {
1617
+ type: 'button',
1618
+ role: 'switch',
1619
+ className: 'dshUS-switch',
1620
+ 'aria-checked': preferences[setting.key],
1621
+ 'aria-label': t(setting.label),
1622
+ 'aria-describedby': descriptionId + setting.key,
1623
+ onClick: () => onChangePreference(setting.key, !preferences[setting.key]),
1624
+ },
1625
+ h('span', { className: 'dshUS-switchThumb' }),
1626
+ ),
1627
+ )),
1628
+ ),
1629
+ ),
1630
+ ),
1631
+ document.body,
1632
+ );
1633
+ }
1634
+
1635
+ /**
1636
+ * The host's "Stop and archive" confirmation, rebuilt here: an archive the
1637
+ * Host refused because work is still running asks once, lists that work,
1638
+ * and only the explicit confirm asks the Host to stop it and archive.
1639
+ * @param props - the pending request, the stop-and-archive hop, settlement, and the translator.
1640
+ * @returns the dialog, or null while nothing is pending.
1641
+ */
1642
+ function ArchiveConfirmDialog({ request, stopAndArchive, onSettle, t }) {
1643
+ if (request === null) return null;
1644
+ // The dialog leaves the sidebar column: the section's own node is inside a
1645
+ // scrolling, clipping column, so a frame-wide modal renders on the body.
1646
+ // `key` restates the request identity on the element (React.createElement
1647
+ // takes keys through props, not a trailing argument), so answering one
1648
+ // question never reuses another Session's in-flight/error state.
1649
+ return createPortal(
1650
+ h(ArchiveConfirmForm, {
1651
+ key: request.sessionId,
1652
+ request,
1653
+ stopAndArchive,
1654
+ onSettle,
1655
+ t,
1656
+ }),
1657
+ document.body,
1658
+ );
1659
+ }
1660
+
1661
+ /** One request's dialog; in-flight and error state die with the request. */
1662
+ function ArchiveConfirmForm({ request, stopAndArchive, onSettle, t }) {
1663
+ const [archiving, setArchiving] = React.useState(false);
1664
+ const [error, setError] = React.useState(null);
1665
+ const dialog = React.useRef(null);
1666
+ // Escape closes the dialog, but never while the Host call is in flight, so
1667
+ // the listener reads the latest state through a ref instead of capturing
1668
+ // the first render's closure.
1669
+ const archivingRef = React.useRef(archiving);
1670
+ archivingRef.current = archiving;
1671
+ const settle = React.useRef(onSettle);
1672
+ settle.current = onSettle;
1673
+ // Focus moves into the dialog on open (the host's Modal does the same), so
1674
+ // Escape acts on it instead of the sidebar behind the mask.
1675
+ React.useEffect(() => {
1676
+ dialog.current?.focus();
1677
+ }, []);
1678
+ React.useEffect(() => {
1679
+ const onKeyDown = (event) => {
1680
+ if (event.key !== 'Escape') return;
1681
+ event.preventDefault();
1682
+ if (archivingRef.current) return;
1683
+ settle.current();
1684
+ };
1685
+ document.addEventListener('keydown', onKeyDown, true);
1686
+ return () => {
1687
+ document.removeEventListener('keydown', onKeyDown, true);
1688
+ };
1689
+ }, []);
1690
+ const close = () => {
1691
+ if (archiving) return;
1692
+ onSettle();
1693
+ };
1694
+ const confirm = () => {
1695
+ setArchiving(true);
1696
+ setError(null);
1697
+ stopAndArchive(request.sessionId)
1698
+ .then(() => {
1699
+ setArchiving(false);
1700
+ onSettle();
1701
+ })
1702
+ .catch((reason) => {
1703
+ setArchiving(false);
1704
+ setError(reason instanceof Error ? reason.message : String(reason));
1705
+ });
1706
+ };
1707
+ return h(
1708
+ 'div',
1709
+ { className: 'dshUS-dialogRoot', role: 'presentation' },
1710
+ h('div', { className: 'dshUS-dialogMask', 'aria-hidden': 'true', onClick: close }),
1711
+ h(
1712
+ 'div',
1713
+ {
1714
+ ref: dialog,
1715
+ tabIndex: -1,
1716
+ className: 'dshUS-dialog',
1717
+ role: 'dialog',
1718
+ 'aria-modal': 'true',
1719
+ 'aria-label': t('archiveConfirm.title'),
1720
+ },
1721
+ h(
1722
+ 'div',
1723
+ { className: 'dshUS-dialogHeader' },
1724
+ h('h2', { className: 'dshUS-dialogTitle' }, t('archiveConfirm.title')),
1725
+ h(
1726
+ 'button',
1727
+ {
1728
+ type: 'button',
1729
+ className: 'dshUS-dialogClose',
1730
+ 'aria-label': t('archiveConfirm.close'),
1731
+ onClick: close,
1732
+ },
1733
+ h(CloseGlyph, { size: 14 }),
1734
+ ),
1735
+ ),
1736
+ h(
1737
+ 'div',
1738
+ { className: 'dshUS-dialogBody' },
1739
+ h(
1740
+ 'p',
1741
+ { className: 'dshUS-dialogDesc' },
1742
+ t('archiveConfirm.desc', { title: request.title }),
1743
+ ),
1744
+ h(
1745
+ 'ul',
1746
+ { className: 'dshUS-activity', 'aria-label': t('archiveConfirm.activity') },
1747
+ request.activity.map((entry, index) =>
1748
+ h('li', { key: `${entry.kind}-${String(index)}` }, activityLine(entry, t)),
1749
+ ),
1750
+ ),
1751
+ archiving &&
1752
+ h(
1753
+ 'div',
1754
+ { className: 'dshUS-dialogStatus', role: 'status' },
1755
+ t('archiveConfirm.pending'),
1756
+ ),
1757
+ error !== null &&
1758
+ h('div', { className: 'dshUS-dialogError', role: 'alert' }, error),
1759
+ ),
1760
+ h(
1761
+ 'div',
1762
+ { className: 'dshUS-dialogFooter' },
1763
+ h(
1764
+ 'button',
1765
+ {
1766
+ type: 'button',
1767
+ className: 'dshUS-button dshUS-buttonOutline',
1768
+ disabled: archiving,
1769
+ onClick: close,
1770
+ },
1771
+ t('archiveConfirm.cancel'),
1772
+ ),
1773
+ h(
1774
+ 'button',
1775
+ {
1776
+ type: 'button',
1777
+ className: 'dshUS-button dshUS-buttonOutline dshUS-buttonDanger',
1778
+ disabled: archiving,
1779
+ onClick: confirm,
1780
+ },
1781
+ t('archiveConfirm.action'),
1782
+ ),
1783
+ ),
1784
+ ),
1785
+ );
1786
+ }
1787
+
1788
+ /** Insert the stylesheet once per plugin generation and remove it on unload. */
1789
+ function mountStyle(ctx) {
1790
+ const tagId = 'dsh-up-session/PinnedSection.css';
1791
+ ctx.effect(() => {
1792
+ if (document.querySelector(`style[data-plugin-css="${tagId}"]`) !== null) return () => {};
1793
+ const tag = document.createElement('style');
1794
+ tag.dataset.plugin = 'dsh-up-session';
1795
+ tag.dataset.pluginCss = tagId;
1796
+ tag.textContent = CSS;
1797
+ document.head.appendChild(tag);
1798
+ return () => {
1799
+ tag.remove();
1800
+ };
1801
+ }, 'up-session: stylesheet');
1802
+ }
1803
+
1804
+ /**
1805
+ * Copy the right edge of a native session row onto the section, so the two
1806
+ * stop on the same line without predicting the host's padding chain (which
1807
+ * arithmetic got wrong twice). Measured, not assumed.
1808
+ * @param host - the section's mount node.
1809
+ */
1810
+ function alignToNativeRows(host) {
1811
+ const row = document.querySelector('[data-slot="sidebar.workspaces"] [data-row-key^="session:"]');
1812
+ if (!(row instanceof Element)) return;
1813
+ const rowRect = row.getBoundingClientRect();
1814
+ const hostRect = host.getBoundingClientRect();
1815
+ if (rowRect.width <= 0 || hostRect.width <= 0) return;
1816
+ const style = getComputedStyle(host);
1817
+ const leftPad = parseFloat(style.paddingLeft) || 0;
1818
+ // Width the host's rows would need to end exactly where the native row ends.
1819
+ const needed = rowRect.right - hostRect.left - leftPad;
1820
+ if (!Number.isFinite(needed) || needed < 0) return;
1821
+ // Solve the padding that leaves exactly that width for the rows.
1822
+ const padding = Math.max(0, host.clientWidth - leftPad - needed);
1823
+ if (Math.abs(padding - (parseFloat(style.paddingRight) || 0)) < 0.5) return;
1824
+ host.style.paddingRight = `${padding}px`;
1825
+ }
1826
+
1827
+ /**
1828
+ * The activity a Host `workspace/session-active` refusal reported, or nothing
1829
+ * for any other failure. The class identity check goes by name, because a
1830
+ * Client plugin bundle does not share the host package's error-class identity.
1831
+ * @param reason - the rejection of a plain archive attempt.
1832
+ * @returns the activity families, or undefined when this was some other failure.
1833
+ */
1834
+ function activeSessionRefusal(reason) {
1835
+ if (!(reason instanceof Error) || reason.name !== 'WorkspaceArchiveError') return undefined;
1836
+ const { rpcError } = reason;
1837
+ return rpcError?.code === 'workspace/session-active' ? rpcError.details?.activity : undefined;
1838
+ }
1839
+
1840
+ /**
1841
+ * Keep the host node immediately before the workspace slot's anchor. The
1842
+ * workspaces seat lives in the sidebar's column flex container, so a sibling
1843
+ * inserted there takes its own space and pushes the browser down instead of
1844
+ * covering it. Both sections share that node and stack inside it. Hiding in
1845
+ * the collapsed rail mirrors the shell's own rule.
1846
+ * @param host - the container the portal renders into.
1847
+ * @returns the disposer that detaches the node and its observers.
1848
+ */
1849
+ function mountHost(host) {
1850
+ let frame = 0;
1851
+ let resize;
1852
+
1853
+ const place = () => {
1854
+ const anchor = document.querySelector('[data-slot="sidebar.workspaces"]');
1855
+ if (!(anchor instanceof Element)) return;
1856
+ const parent = anchor.parentElement;
1857
+ if (parent === null) return;
1858
+ if (host.parentElement !== parent || host.nextElementSibling !== anchor) {
1859
+ parent.insertBefore(host, anchor);
1860
+ }
1861
+ if (resize === undefined) {
1862
+ // The seat spans the sidebar's inner width, which is narrower than the
1863
+ // column by its inline padding; the rail seat is far narrower still.
1864
+ resize = new ResizeObserver((entries) => {
1865
+ const width = entries[0]?.contentRect.width ?? 0;
1866
+ host.dataset.rail = width < 200 ? 'true' : 'false';
1867
+ alignToNativeRows(host);
1868
+ });
1869
+ resize.observe(parent);
1870
+ }
1871
+ alignToNativeRows(host);
1872
+ };
1873
+
1874
+ const schedule = () => {
1875
+ if (frame !== 0) return;
1876
+ frame = requestAnimationFrame(() => {
1877
+ frame = 0;
1878
+ place();
1879
+ });
1880
+ };
1881
+
1882
+ place();
1883
+ const observer = new MutationObserver(schedule);
1884
+ observer.observe(document.documentElement, { childList: true, subtree: true });
1885
+
1886
+ return () => {
1887
+ observer.disconnect();
1888
+ resize?.disconnect();
1889
+ if (frame !== 0) cancelAnimationFrame(frame);
1890
+ host.remove();
1891
+ };
1892
+ }
1893
+
1894
+ /** Contribute the pinned and recent sections above the workspace list. */
1895
+ function apply(ctx) {
1896
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'up-session: dictionaries');
1897
+ const t = ctx.locale.bind(NS);
1898
+ mountStyle(ctx);
1899
+
1900
+ // The portal host is created with the plugin and removed by its effect, so
1901
+ // a reload never leaves an orphan node in the sidebar.
1902
+ const host = document.createElement('div');
1903
+ host.dataset.dshUpSession = '';
1904
+ host.className = 'dshUS-mount';
1905
+ ctx.effect(() => mountHost(host), 'up-session: sidebar section host');
1906
+ // Both entry surfaces send requests to the single sidebar-owned dialog.
1907
+ const settingsEvents = new EventTarget();
1908
+
1909
+ ctx.slots.inject('plugins.bundle.config', () =>
1910
+ ctx.slots.register(
1911
+ {
1912
+ name: 'plugins.bundle.config',
1913
+ key: 'dsh-up-session',
1914
+ locale: NS,
1915
+ inject: () => ({ onOpenSettings: () => settingsEvents.dispatchEvent(new Event('open')) }),
1916
+ },
1917
+ PluginSettingsEntry,
1918
+ ),
1919
+ );
1920
+
1921
+ const injected = () => ({
1922
+ host,
1923
+ settingsEvents,
1924
+ open: (sessionId) => {
1925
+ ctx.uiWorkspace.openSession(sessionId);
1926
+ },
1927
+ // Pin and unpin go through the same hop the sidebar's own row actions
1928
+ // use; a failure stays in the log, like an unpin failure always did.
1929
+ pin: (sessionId) => {
1930
+ ctx.uiWorkspace.pinSession(sessionId).catch((reason) => {
1931
+ console.warn('session pin rejected:', reason);
1932
+ });
1933
+ },
1934
+ unpin: (sessionId) => {
1935
+ ctx.uiWorkspace.unpinSession(sessionId).catch((reason) => {
1936
+ console.warn('session unpin rejected:', reason);
1937
+ });
1938
+ },
1939
+ // Mirrors the sidebar's own archive action: try plainly first, and only
1940
+ // the Host's "still running" refusal (workspace/session-active, whose
1941
+ // details list that work) is reported back so the row can raise the
1942
+ // confirmation. Anything else is a real failure and stays in the log.
1943
+ archive: async (sessionId) => {
1944
+ try {
1945
+ await ctx.uiWorkspace.archiveSession(sessionId);
1946
+ return { refused: false };
1947
+ } catch (reason) {
1948
+ const activity = activeSessionRefusal(reason);
1949
+ if (activity === undefined) {
1950
+ console.warn('session archive rejected:', reason);
1951
+ return { refused: false };
1952
+ }
1953
+ return { refused: true, activity };
1954
+ }
1955
+ },
1956
+ stopAndArchive: (sessionId) => ctx.uiWorkspace.archiveSession(sessionId, { stopActivity: true }),
1957
+ });
1958
+
1959
+ ctx.slots.inject('shell.overlay', () =>
1960
+ ctx.slots.register(
1961
+ {
1962
+ name: 'shell.overlay',
1963
+ id: 'dsh-up-session',
1964
+ locale: NS,
1965
+ inject: injected,
1966
+ },
1967
+ SidebarSections,
1968
+ ),
1969
+ );
1970
+ }
1971
+
1972
+ return {
1973
+ inject,
1974
+ apply,
1975
+ };
1976
+ },
1977
+ });