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/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.en.md +70 -0
- package/README.md +69 -2
- package/assets/screenshot.png +0 -0
- package/assets/settings.png +0 -0
- package/client.js +1977 -0
- package/cordis.patch.yml +6 -0
- package/icon.svg +7 -0
- package/index.js +8 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +60 -4
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
|
+
});
|