dsh-workspace-status 0.1.0 → 0.3.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 +49 -8
- package/README.md +186 -38
- package/README.zh-CN.md +83 -0
- package/client.js +1110 -60
- package/locale/en.json +29 -2
- package/locale/zh.json +29 -2
- package/package.json +12 -5
package/client.js
CHANGED
|
@@ -1,39 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Browser half of workspace-status.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* Workspace (folder) row above it, so a collapsed group — or a group scrolled
|
|
6
|
-
* out of view — hides whether any agent inside it is waiting, running, or
|
|
7
|
-
* just finished. Workspace rows declare no slot, so this package does not
|
|
8
|
-
* render markup into them: it publishes one stylesheet aimed at the row the
|
|
9
|
-
* Workspace browser already renders (`data-row-key="workspace:<id>"`) and
|
|
10
|
-
* draws the mark as that row's trailing pseudo-element. React keeps owning
|
|
11
|
-
* the row; nothing is written to the document and nothing is observed.
|
|
4
|
+
* Two surfaces, one plugin:
|
|
12
5
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
6
|
+
* 1. **Workspace-row status marks.** The sidebar draws a status dot on every
|
|
7
|
+
* Session row but nothing on the Workspace (folder) row above it, so a
|
|
8
|
+
* collapsed group hides whether a session inside is waiting, running, or
|
|
9
|
+
* just finished. Workspace rows declare no slot, so the mark is published as
|
|
10
|
+
* a stylesheet aimed at the row the browser already renders
|
|
11
|
+
* (`data-row-key="workspace:<id>"`), drawn as its trailing pseudo-element.
|
|
12
|
+
*
|
|
13
|
+
* 2. **Pinned section.** Conversations pinned through the shipped pin action
|
|
14
|
+
* are lifted out of the regular workspace groups into a dedicated section
|
|
15
|
+
* above the browsing region, grouped per Workspace with the same header
|
|
16
|
+
* treatment. The section is rendered through a portal into a container
|
|
17
|
+
* inserted before the sidebar's region area; the pinned rows are hidden from
|
|
18
|
+
* the official list with generated `display:none` rules. Pin state is the
|
|
19
|
+
* registry-global `pinnedSessionIds` feed, so it persists across restarts and
|
|
20
|
+
* unpinning updates both surfaces immediately.
|
|
21
|
+
*
|
|
22
|
+
* The footer entry stays the mount: it publishes the stylesheet, the hidden
|
|
23
|
+
* description of workspace activity, and the portal host.
|
|
17
24
|
*
|
|
18
25
|
* Data comes from the same public feeds the browser itself reads:
|
|
19
|
-
* `useWorkspaces` (items
|
|
20
|
-
* subagent rows) and `useSessionStatus` (running / pendingInteraction
|
|
21
|
-
* completionUnread).
|
|
22
|
-
*
|
|
23
|
-
* reminder; a subagent keeps its parent's workspace busy.
|
|
26
|
+
* `useWorkspaces` (items, archive set, pin set), `useSessions` (summaries,
|
|
27
|
+
* including subagent rows) and `useSessionStatus` (running / pendingInteraction
|
|
28
|
+
* / completionUnread). Pin, unpin and open are the shipped client services
|
|
29
|
+
* (`ctx.workspaces`, `ctx.uiWorkspace`).
|
|
24
30
|
*/
|
|
25
31
|
|
|
26
32
|
window.__ModuleLoader__.load({
|
|
27
33
|
id: 'dsh-workspace-status',
|
|
28
34
|
factory(require) {
|
|
29
35
|
const React = require('react')
|
|
36
|
+
const ReactDOM = require('react-dom')
|
|
37
|
+
const h = React.createElement
|
|
30
38
|
|
|
31
39
|
/** Additive seat beside Settings at the sidebar foot. */
|
|
32
40
|
const SLOT = 'sidebar.footer.action'
|
|
33
41
|
/** Stable list id; reusing it would shadow this entry rather than add one. */
|
|
34
42
|
const ENTRY_ID = 'workspace-status'
|
|
35
43
|
/** The stylesheet this mount owns; unmounting the entry removes it. */
|
|
36
|
-
const STYLE_ID = 'dsh-workspace-status/
|
|
44
|
+
const STYLE_ID = 'dsh-workspace-status/sidebar.css'
|
|
37
45
|
/** Last among the shipped footer actions. */
|
|
38
46
|
const ENTRY_ORDER = 950
|
|
39
47
|
|
|
@@ -55,21 +63,36 @@ window.__ModuleLoader__.load({
|
|
|
55
63
|
/** The visually hidden description's class. */
|
|
56
64
|
const SR_CLASS = 'dsws-sr'
|
|
57
65
|
|
|
66
|
+
/** Attribute marking the portal container inserted above the browser. */
|
|
67
|
+
const SECTION_ATTR = 'data-dsws-pinned'
|
|
68
|
+
|
|
69
|
+
/** Browser-local collapse memory for the pinned section. */
|
|
70
|
+
const VIEW_STORE_KEY = 'dsh-workspace-status/pinned-view/v1'
|
|
71
|
+
|
|
72
|
+
/** Browser-local preference store for pinned-conversation notifications. */
|
|
73
|
+
const NOTIFY_STORE_KEY = 'dsh-workspace-status/notifications/v1'
|
|
74
|
+
|
|
75
|
+
/** The renderer's Notification constructor, when the platform has one. */
|
|
76
|
+
const NOTIFICATION_API = typeof Notification === 'undefined' ? undefined : Notification
|
|
77
|
+
|
|
78
|
+
/** The insertion anchor: SidebarRoot's region area owns the browsing slot. */
|
|
79
|
+
const REGION_SELECTOR = '[class*="_regionArea"]'
|
|
80
|
+
|
|
58
81
|
/**
|
|
59
|
-
* The hidden description's own rule. The visible
|
|
60
|
-
*
|
|
61
|
-
*
|
|
82
|
+
* The hidden description's own rule. The visible marks carry no markup of
|
|
83
|
+
* their own, so this sheet only has to keep the description and the portal
|
|
84
|
+
* anchor out of the layout.
|
|
62
85
|
*/
|
|
63
86
|
const BASE_CSS = [
|
|
64
87
|
`.${SR_CLASS}{position:absolute;width:1px;height:1px;margin:-1px;padding:0;`,
|
|
65
88
|
'overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}',
|
|
89
|
+
'.dsws-anchor{display:none!important}',
|
|
66
90
|
].join('')
|
|
67
91
|
|
|
68
92
|
/**
|
|
69
93
|
* Every user-facing string lives here rather than in a locale service: the
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* face. The `locale/*.json` files mirror them for tooling.
|
|
94
|
+
* plugin keeps working without the locale face, and the `locale/*.json`
|
|
95
|
+
* files mirror these dictionaries for tooling.
|
|
73
96
|
*/
|
|
74
97
|
const DICTIONARIES = {
|
|
75
98
|
en: {
|
|
@@ -80,6 +103,34 @@ window.__ModuleLoader__.load({
|
|
|
80
103
|
'activity.unnamed': 'Ungrouped',
|
|
81
104
|
'activity.more': 'and {n} more',
|
|
82
105
|
'activity.listSeparator': ', ',
|
|
106
|
+
'pinned.title': 'Pinned',
|
|
107
|
+
'pinned.untitled': 'Untitled',
|
|
108
|
+
'pinned.unpin': 'Unpin',
|
|
109
|
+
'pinned.unpinAria': 'Unpin {title}',
|
|
110
|
+
'pinned.sectionAria': 'Pinned conversations',
|
|
111
|
+
'pinned.groupAria': 'Pinned in {workspace}',
|
|
112
|
+
'pinned.rail': 'Pinned conversations',
|
|
113
|
+
'time.now': 'now',
|
|
114
|
+
'time.minutes': '{n}min',
|
|
115
|
+
'time.hours': '{n}h',
|
|
116
|
+
'time.days': '{n}d',
|
|
117
|
+
'notify.enable': 'Enable notifications for pinned conversations',
|
|
118
|
+
'notify.disable': 'Turn notifications off',
|
|
119
|
+
'notify.permissionNeeded': 'Click to allow notifications',
|
|
120
|
+
'notify.blocked': 'Notifications are blocked by the system',
|
|
121
|
+
'notify.soundOn': 'Turn notification sound off',
|
|
122
|
+
'notify.soundOff': 'Turn notification sound on',
|
|
123
|
+
'notify.enabled.title': 'Pinned notifications on',
|
|
124
|
+
'notify.enabled.body': 'Pinned conversations will notify you when they need you or finish.',
|
|
125
|
+
'notify.waitingReason.approval': 'Waiting for approval',
|
|
126
|
+
'notify.waitingReason.planReview': 'Plan awaiting review',
|
|
127
|
+
'notify.waitingReason.question': 'Waiting for an answer',
|
|
128
|
+
'notify.waitingReason.default': 'Waiting for you',
|
|
129
|
+
'notify.waiting.title': '{title} needs you',
|
|
130
|
+
'notify.waiting.body': '{reason} · {workspace}',
|
|
131
|
+
'notify.finished.title': '{title} finished',
|
|
132
|
+
'notify.finished.body': 'Finished and not opened yet · {workspace}',
|
|
133
|
+
'notify.dismiss': 'Dismiss',
|
|
83
134
|
},
|
|
84
135
|
zh: {
|
|
85
136
|
'activity.summary': '工作区状态:{clauses} — {names}',
|
|
@@ -89,6 +140,34 @@ window.__ModuleLoader__.load({
|
|
|
89
140
|
'activity.unnamed': '未分组',
|
|
90
141
|
'activity.more': '等 {n} 个',
|
|
91
142
|
'activity.listSeparator': '、',
|
|
143
|
+
'pinned.title': '已置顶',
|
|
144
|
+
'pinned.untitled': '未命名',
|
|
145
|
+
'pinned.unpin': '取消置顶',
|
|
146
|
+
'pinned.unpinAria': '取消置顶 {title}',
|
|
147
|
+
'pinned.sectionAria': '已置顶会话',
|
|
148
|
+
'pinned.groupAria': '{workspace} 中的已置顶会话',
|
|
149
|
+
'pinned.rail': '已置顶会话',
|
|
150
|
+
'time.now': '刚刚',
|
|
151
|
+
'time.minutes': '{n} 分钟',
|
|
152
|
+
'time.hours': '{n} 小时',
|
|
153
|
+
'time.days': '{n} 天',
|
|
154
|
+
'notify.enable': '开启已置顶会话通知',
|
|
155
|
+
'notify.disable': '关闭通知',
|
|
156
|
+
'notify.permissionNeeded': '点击以允许通知',
|
|
157
|
+
'notify.blocked': '系统已阻止通知',
|
|
158
|
+
'notify.soundOn': '关闭提示音',
|
|
159
|
+
'notify.soundOff': '开启提示音',
|
|
160
|
+
'notify.enabled.title': '已开启置顶通知',
|
|
161
|
+
'notify.enabled.body': '已置顶会话在需要你或完成时会通知你。',
|
|
162
|
+
'notify.waitingReason.approval': '等待审批',
|
|
163
|
+
'notify.waitingReason.planReview': '计划待审',
|
|
164
|
+
'notify.waitingReason.question': '等待回答',
|
|
165
|
+
'notify.waitingReason.default': '等待你处理',
|
|
166
|
+
'notify.waiting.title': '{title} 需要你',
|
|
167
|
+
'notify.waiting.body': '{reason} · {workspace}',
|
|
168
|
+
'notify.finished.title': '{title} 已完成',
|
|
169
|
+
'notify.finished.body': '已完成且尚未打开 · {workspace}',
|
|
170
|
+
'notify.dismiss': '关闭',
|
|
92
171
|
},
|
|
93
172
|
}
|
|
94
173
|
|
|
@@ -129,16 +208,15 @@ window.__ModuleLoader__.load({
|
|
|
129
208
|
}
|
|
130
209
|
|
|
131
210
|
/**
|
|
132
|
-
* Escape one
|
|
211
|
+
* Escape one value for a double-quoted CSS attribute selector.
|
|
133
212
|
*
|
|
134
213
|
* A control character has no literal form inside a CSS string: an unescaped
|
|
135
|
-
* newline ends the string token and the whole rule is dropped as invalid
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* @
|
|
141
|
-
* @returns the key escaped for use inside quotes.
|
|
214
|
+
* newline ends the string token and the whole rule is dropped as invalid.
|
|
215
|
+
* Each one is written as a hex escape with a trailing space, which stops a
|
|
216
|
+
* following hex digit being absorbed. Host ids are UUIDs, so this is a
|
|
217
|
+
* correctness guard, not a path expected in practice.
|
|
218
|
+
* @param value - raw id or key.
|
|
219
|
+
* @returns the value escaped for use inside quotes.
|
|
142
220
|
*/
|
|
143
221
|
function quote(value) {
|
|
144
222
|
let escaped = ''
|
|
@@ -156,11 +234,61 @@ window.__ModuleLoader__.load({
|
|
|
156
234
|
*
|
|
157
235
|
* A CSS border ring cannot read as a circle at this size: its corners are
|
|
158
236
|
* mitred, so the gap between the two ends looks like a clipped square. The
|
|
159
|
-
* mask keeps a round
|
|
160
|
-
*
|
|
237
|
+
* mask keeps a round stroke with round caps and a faint track, matching the
|
|
238
|
+
* geometry of the product's own running indicator.
|
|
161
239
|
*/
|
|
162
240
|
const RING = "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'%3E%3Ccircle cx='5' cy='5' r='4' fill='none' stroke='%23000' stroke-width='1.5' stroke-linecap='round' stroke-opacity='.25'/%3E%3Ccircle cx='5' cy='5' r='4' fill='none' stroke='%23000' stroke-width='1.5' stroke-linecap='round' stroke-dasharray='5 20'/%3E%3C/svg%3E\")"
|
|
163
241
|
|
|
242
|
+
/** The sidebar's own right-pointing disclosure triangle, drawn inline. */
|
|
243
|
+
const CHEVRON_PATH = 'M3.4 2.05 9.4 8l-6 5.95V2.05z'
|
|
244
|
+
|
|
245
|
+
/** Closed-folder glyph in the shell's 16px slot. */
|
|
246
|
+
const FOLDER_PATH = 'M1.6 3.9c0-.66.54-1.2 1.2-1.2h3.02c.32 0 .62.13.85.36l.7.7c.22.23.53.36.85.36h4.58c.66 0 1.2.54 1.2 1.2v7.03c0 .66-.54 1.2-1.2 1.2H2.8c-.66 0-1.2-.54-1.2-1.2V3.9z'
|
|
247
|
+
|
|
248
|
+
/** Material push-pin path on a 24px grid. */
|
|
249
|
+
const PIN_PATH = 'M16 9V4h1c.55 0 1-.45 1-1s-.45-1-1-1H7c-.55 0-1 .45-1 1s.45 1 1 1h1v5c0 1.66-1.34 3-3 3v2h5.97v7l1 1 1-1v-7H19v-2c-1.66 0-3-1.34-3-3z'
|
|
250
|
+
|
|
251
|
+
/** Render the disclosure triangle, rotated by the caller's CSS class. */
|
|
252
|
+
function chevronIcon() {
|
|
253
|
+
return h('svg', {
|
|
254
|
+
width: 12, height: 12, viewBox: '0 0 12 12', 'aria-hidden': 'true', focusable: 'false',
|
|
255
|
+
}, h('path', { d: CHEVRON_PATH, fill: 'currentColor' }))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Render the closed-folder glyph. */
|
|
259
|
+
function folderIcon() {
|
|
260
|
+
return h('svg', {
|
|
261
|
+
width: 16, height: 16, viewBox: '0 0 16 16', 'aria-hidden': 'true', focusable: 'false',
|
|
262
|
+
}, h('path', { d: FOLDER_PATH, fill: 'currentColor' }))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Render the push-pin glyph used by the section header and row buttons. */
|
|
266
|
+
function pinIcon(size = 14) {
|
|
267
|
+
return h('svg', {
|
|
268
|
+
width: size, height: size, viewBox: '0 0 24 24', 'aria-hidden': 'true', focusable: 'false',
|
|
269
|
+
}, h('path', { d: PIN_PATH, fill: 'currentColor' }))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Material bell glyph for the notification toggle. */
|
|
273
|
+
const BELL_PATH = 'M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z'
|
|
274
|
+
|
|
275
|
+
/** Material volume glyph for the optional chime toggle. */
|
|
276
|
+
const VOLUME_PATH = 'M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z'
|
|
277
|
+
|
|
278
|
+
/** Render the notification bell. */
|
|
279
|
+
function bellIcon() {
|
|
280
|
+
return h('svg', {
|
|
281
|
+
width: 14, height: 14, viewBox: '0 0 24 24', 'aria-hidden': 'true', focusable: 'false',
|
|
282
|
+
}, h('path', { d: BELL_PATH, fill: 'currentColor' }))
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Render the sound toggle glyph. */
|
|
286
|
+
function volumeIcon() {
|
|
287
|
+
return h('svg', {
|
|
288
|
+
width: 14, height: 14, viewBox: '0 0 24 24', 'aria-hidden': 'true', focusable: 'false',
|
|
289
|
+
}, h('path', { d: VOLUME_PATH, fill: 'currentColor' }))
|
|
290
|
+
}
|
|
291
|
+
|
|
164
292
|
/**
|
|
165
293
|
* One Workspace's tally before the states are split.
|
|
166
294
|
* @typedef {{ key: string, title: string, waiting: number, running: number, done: number }} GroupTally
|
|
@@ -214,6 +342,57 @@ window.__ModuleLoader__.load({
|
|
|
214
342
|
return false
|
|
215
343
|
}
|
|
216
344
|
|
|
345
|
+
/** Direct subagent children per parent Session id. */
|
|
346
|
+
function childMap(sessions) {
|
|
347
|
+
const children = new Map()
|
|
348
|
+
for (const sessionId of Object.keys(sessions)) {
|
|
349
|
+
const row = sessions[sessionId]
|
|
350
|
+
if (row?.origin !== 'subagent' || typeof row.parentId !== 'string') continue
|
|
351
|
+
const list = children.get(row.parentId)
|
|
352
|
+
if (list === undefined) children.set(row.parentId, [sessionId])
|
|
353
|
+
else list.push(sessionId)
|
|
354
|
+
}
|
|
355
|
+
return children
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* One Session's live mark, matching the shipped row precedence.
|
|
360
|
+
* @param sessionId - Session to classify.
|
|
361
|
+
* @param row - its summary.
|
|
362
|
+
* @param statuses - Session UI status map.
|
|
363
|
+
* @param children - subagent children map.
|
|
364
|
+
* @param sessions - all summaries (subagent fallback).
|
|
365
|
+
* @returns `warning`, `ongoing`, `done`, or undefined for idle.
|
|
366
|
+
*/
|
|
367
|
+
function sessionFacts(sessionId, row, statuses, children, sessions) {
|
|
368
|
+
const status = statuses?.get?.(sessionId)
|
|
369
|
+
const interaction = status?.pendingInteraction
|
|
370
|
+
const pendingKind = interaction !== undefined && interaction !== null
|
|
371
|
+
? typeof interaction.kind === 'string' && interaction.kind !== ''
|
|
372
|
+
? interaction.kind
|
|
373
|
+
: 'default'
|
|
374
|
+
: null
|
|
375
|
+
const running = status?.running === true
|
|
376
|
+
|| (status?.running === undefined && row?.running === true)
|
|
377
|
+
|| runsChild(sessionId, children, sessions, statuses)
|
|
378
|
+
const completionUnread = status?.completionUnread === true
|
|
379
|
+
const mark = pendingKind !== null ? 'warning' : running ? 'ongoing' : completionUnread ? 'done' : undefined
|
|
380
|
+
return { pendingKind, running, completionUnread, mark }
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* One Session's live mark, matching the shipped row precedence.
|
|
385
|
+
* @param sessionId - Session to classify.
|
|
386
|
+
* @param row - its summary.
|
|
387
|
+
* @param statuses - Session UI status map.
|
|
388
|
+
* @param children - subagent children map.
|
|
389
|
+
* @param sessions - all summaries (subagent fallback).
|
|
390
|
+
* @returns `warning`, `ongoing`, `done`, or undefined for idle.
|
|
391
|
+
*/
|
|
392
|
+
function sessionMark(sessionId, row, statuses, children, sessions) {
|
|
393
|
+
return sessionFacts(sessionId, row, statuses, children, sessions).mark
|
|
394
|
+
}
|
|
395
|
+
|
|
217
396
|
/**
|
|
218
397
|
* Which Workspace groups own a waiting, running, or just-finished Session.
|
|
219
398
|
*
|
|
@@ -252,15 +431,7 @@ window.__ModuleLoader__.load({
|
|
|
252
431
|
}
|
|
253
432
|
}
|
|
254
433
|
|
|
255
|
-
|
|
256
|
-
const children = new Map()
|
|
257
|
-
for (const sessionId of Object.keys(sessions)) {
|
|
258
|
-
const row = sessions[sessionId]
|
|
259
|
-
if (row?.origin !== 'subagent' || typeof row.parentId !== 'string') continue
|
|
260
|
-
const list = children.get(row.parentId)
|
|
261
|
-
if (list === undefined) children.set(row.parentId, [sessionId])
|
|
262
|
-
else list.push(sessionId)
|
|
263
|
-
}
|
|
434
|
+
const children = childMap(sessions)
|
|
264
435
|
|
|
265
436
|
/** @type {Map<string, GroupTally>} */
|
|
266
437
|
const groups = new Map()
|
|
@@ -384,7 +555,6 @@ window.__ModuleLoader__.load({
|
|
|
384
555
|
if (activity.running.length > 0) {
|
|
385
556
|
const running = selectors(activity.running, '::after')
|
|
386
557
|
parts.push(
|
|
387
|
-
'@keyframes dsws-spin{to{transform:rotate(360deg)}}',
|
|
388
558
|
`${running}{content:"";flex:none;box-sizing:border-box;width:10px;height:10px;`,
|
|
389
559
|
'margin-inline-start:2px;background-color:var(--dsw-static-deepseek-450);',
|
|
390
560
|
`-webkit-mask-image:${RING};mask-image:${RING};`,
|
|
@@ -443,6 +613,795 @@ window.__ModuleLoader__.load({
|
|
|
443
613
|
})
|
|
444
614
|
}
|
|
445
615
|
|
|
616
|
+
/** A short relative label matching the row timestamps the sidebar shows. */
|
|
617
|
+
function relativeTime(updatedAt, now) {
|
|
618
|
+
if (typeof updatedAt !== 'number' || !Number.isFinite(updatedAt) || updatedAt <= 0) return ''
|
|
619
|
+
const elapsed = Math.max(0, now - updatedAt)
|
|
620
|
+
const minute = 60 * 1000
|
|
621
|
+
const hour = 60 * minute
|
|
622
|
+
const day = 24 * hour
|
|
623
|
+
if (elapsed < minute) return message('time.now')
|
|
624
|
+
if (elapsed < hour) return message('time.minutes', { n: Math.floor(elapsed / minute) })
|
|
625
|
+
if (elapsed < day) return message('time.hours', { n: Math.floor(elapsed / hour) })
|
|
626
|
+
return message('time.days', { n: Math.floor(elapsed / day) })
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Group the registry-global pin set per Workspace for the pinned section.
|
|
631
|
+
*
|
|
632
|
+
* Order follows the pinned feed (most recently pinned first) inside each
|
|
633
|
+
* group, while groups themselves follow the sidebar's Workspace order and
|
|
634
|
+
* end with the ungrouped bucket. Archived, blank and subagent sessions are
|
|
635
|
+
* skipped: they have no ordinary row to lift anyway.
|
|
636
|
+
*
|
|
637
|
+
* @param pinnedSessionIds - ordered registry pin set.
|
|
638
|
+
* @param items - Workspace rows.
|
|
639
|
+
* @param byId - Session summaries.
|
|
640
|
+
* @param statuses - Session UI status map.
|
|
641
|
+
* @param archivedSessionIds - registry archive set.
|
|
642
|
+
* @param now - render clock for the relative label.
|
|
643
|
+
* @returns groups, visible ids (for the hide rules), and the total.
|
|
644
|
+
*/
|
|
645
|
+
function buildPinnedView(pinnedSessionIds, items, byId, statuses, archivedSessionIds, now) {
|
|
646
|
+
const sessions = byId !== null && typeof byId === 'object' ? byId : {}
|
|
647
|
+
const archived = new Set(Array.isArray(archivedSessionIds) ? archivedSessionIds : [])
|
|
648
|
+
const statusMap = statuses !== null && typeof statuses?.get === 'function' ? statuses : undefined
|
|
649
|
+
const children = childMap(sessions)
|
|
650
|
+
|
|
651
|
+
const ownerOf = new Map()
|
|
652
|
+
const titles = new Map()
|
|
653
|
+
const workspaceOrder = []
|
|
654
|
+
for (const workspace of Array.isArray(items) ? items : []) {
|
|
655
|
+
if (workspace === null || typeof workspace !== 'object') continue
|
|
656
|
+
const key = typeof workspace.workspaceId === 'string' ? workspace.workspaceId : ''
|
|
657
|
+
workspaceOrder.push(key)
|
|
658
|
+
titles.set(key, typeof workspace.title === 'string' ? workspace.title : '')
|
|
659
|
+
for (const sessionId of Array.isArray(workspace.sessionIds) ? workspace.sessionIds : []) {
|
|
660
|
+
ownerOf.set(sessionId, key)
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const byGroup = new Map()
|
|
665
|
+
const seen = new Set()
|
|
666
|
+
for (const rawId of Array.isArray(pinnedSessionIds) ? pinnedSessionIds : []) {
|
|
667
|
+
const id = typeof rawId === 'string' ? rawId : String(rawId)
|
|
668
|
+
if (seen.has(id)) continue
|
|
669
|
+
seen.add(id)
|
|
670
|
+
const row = sessions[id]
|
|
671
|
+
if (row === null || row === undefined) continue
|
|
672
|
+
if (row.origin === 'subagent' || row.blank === true) continue
|
|
673
|
+
if (archived.has(id)) continue
|
|
674
|
+
const key = ownerOf.has(id) ? ownerOf.get(id) : ''
|
|
675
|
+
const title = typeof row.displayTitle === 'string' && row.displayTitle !== ''
|
|
676
|
+
? row.displayTitle
|
|
677
|
+
: typeof row.title === 'string' && row.title !== ''
|
|
678
|
+
? row.title
|
|
679
|
+
: message('pinned.untitled')
|
|
680
|
+
const facts = sessionFacts(id, row, statusMap, children, sessions)
|
|
681
|
+
const entry = {
|
|
682
|
+
id,
|
|
683
|
+
title,
|
|
684
|
+
workspace: titles.get(key) ?? '',
|
|
685
|
+
time: relativeTime(row.updatedAt, now),
|
|
686
|
+
mark: facts.mark,
|
|
687
|
+
pendingKind: facts.pendingKind,
|
|
688
|
+
running: facts.running,
|
|
689
|
+
completionUnread: facts.completionUnread,
|
|
690
|
+
current: (row.retainedBy?.mainView ?? 0) > 0,
|
|
691
|
+
}
|
|
692
|
+
const list = byGroup.get(key)
|
|
693
|
+
if (list === undefined) byGroup.set(key, [entry])
|
|
694
|
+
else list.push(entry)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const orderedKeys = workspaceOrder.filter((key) => byGroup.has(key))
|
|
698
|
+
if (byGroup.has('')) orderedKeys.push('')
|
|
699
|
+
|
|
700
|
+
const groups = []
|
|
701
|
+
const ids = []
|
|
702
|
+
for (const key of orderedKeys) {
|
|
703
|
+
const listed = byGroup.get(key)
|
|
704
|
+
if (listed === undefined) continue
|
|
705
|
+
for (const entry of listed) ids.push(entry.id)
|
|
706
|
+
groups.push({
|
|
707
|
+
key,
|
|
708
|
+
title: titles.get(key) ?? '',
|
|
709
|
+
count: listed.length,
|
|
710
|
+
containsCurrent: listed.some((entry) => entry.current),
|
|
711
|
+
sessions: listed,
|
|
712
|
+
})
|
|
713
|
+
}
|
|
714
|
+
return { groups, ids, total: ids.length }
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Hide the pinned rows inside the official browser list.
|
|
719
|
+
*
|
|
720
|
+
* Search-result rows carry no `data-row-key`, so their occurrence stays
|
|
721
|
+
* visible; only the grouped/flat tree rows are lifted into the pinned
|
|
722
|
+
* section.
|
|
723
|
+
* @param ids - Session ids currently rendered in the pinned section.
|
|
724
|
+
* @returns CSS text, empty when nothing is pinned.
|
|
725
|
+
*/
|
|
726
|
+
function hideCss(ids) {
|
|
727
|
+
if (!Array.isArray(ids) || ids.length === 0) return ''
|
|
728
|
+
const rows = ids.map((id) => `[data-row-key="session:${quote(id)}"]`).join(',')
|
|
729
|
+
return `${rows}{display:none!important}`
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Static stylesheet for the pinned section. Geometry mirrors the shipped
|
|
734
|
+
* sidebar: 34px workspace rows, 32px session rows, the same radii, gaps and
|
|
735
|
+
* theme tokens, and a muted section header above them.
|
|
736
|
+
*/
|
|
737
|
+
const PINNED_CSS = [
|
|
738
|
+
'@keyframes dsws-spin{to{transform:rotate(360deg)}}',
|
|
739
|
+
'.dsws-pinned{flex:none;box-sizing:border-box;max-height:min(40vh,320px);overflow-y:auto;overflow-x:hidden;',
|
|
740
|
+
'margin:0 0 6px;padding:0 2px 2px;overscroll-behavior:contain;',
|
|
741
|
+
'--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);',
|
|
742
|
+
'--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2)}',
|
|
743
|
+
'.dsws-pinned[data-rail="true"],.dsws-pinned:empty{display:none}',
|
|
744
|
+
'.dsws-pinned-headerrow{display:flex;align-items:center;gap:2px;margin:0;padding:0 2px 0 0}',
|
|
745
|
+
'.dsws-pinned-header{display:flex;align-items:center;gap:6px;box-sizing:border-box;flex:1;min-width:0;height:30px;',
|
|
746
|
+
'margin:0;padding:0 8px;border:0;border-radius:var(--dsw-radius-md);background:none;',
|
|
747
|
+
'color:var(--dsw-alias-label-tertiary);font:inherit;font-size:13px;line-height:18px;font-weight:500;',
|
|
748
|
+
'cursor:pointer;text-align:left;user-select:none}',
|
|
749
|
+
'.dsws-pinned-header:hover{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
750
|
+
'.dsws-pinned-header:focus-visible,.dsws-pinned-group-head:focus-visible,',
|
|
751
|
+
'.dsws-pinned-row:focus-visible,.dsws-pinned-unpin:focus-visible,.dsws-pinned-tool:focus-visible{',
|
|
752
|
+
'outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));',
|
|
753
|
+
'outline-offset:-2px}',
|
|
754
|
+
'.dsws-pinned-glyph{width:16px;height:16px;flex:none;display:inline-flex;align-items:center;justify-content:center}',
|
|
755
|
+
'.dsws-pinned-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
756
|
+
'.dsws-pinned-count{flex:none;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:17px;',
|
|
757
|
+
'font-variant-numeric:tabular-nums;font-weight:400}',
|
|
758
|
+
'.dsws-pinned-chevron{flex:none;width:12px;height:12px;display:inline-flex;align-items:center;',
|
|
759
|
+
'justify-content:center;color:var(--dsw-alias-label-tertiary);transform:rotate(0deg);',
|
|
760
|
+
'transition:transform .15s ease}',
|
|
761
|
+
'.dsws-pinned-chevron[data-open="true"]{transform:rotate(90deg)}',
|
|
762
|
+
'.dsws-pinned-tools{display:flex;align-items:center;gap:1px;flex:none;padding-right:2px}',
|
|
763
|
+
'.dsws-pinned-tool{width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;',
|
|
764
|
+
'padding:0;border:0;border-radius:var(--dsw-radius-sm);background:none;color:var(--dsw-alias-label-tertiary);',
|
|
765
|
+
'cursor:pointer;flex:none}',
|
|
766
|
+
'.dsws-pinned-tool:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
|
|
767
|
+
'.dsws-pinned-tool[data-on="true"]{color:var(--dsw-alias-label-secondary)}',
|
|
768
|
+
'.dsws-pinned-tool[data-blocked="true"]{opacity:.55}',
|
|
769
|
+
'.dsws-pinned-notices{display:flex;flex-direction:column;gap:2px;margin:2px 4px 4px}',
|
|
770
|
+
'.dsws-pinned-notice{display:flex;align-items:flex-start;gap:6px;box-sizing:border-box;width:100%;',
|
|
771
|
+
'padding:6px 8px;border:0;border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-interactive-bg-hover);',
|
|
772
|
+
'color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:16px;text-align:left;cursor:pointer}',
|
|
773
|
+
'.dsws-pinned-notice:hover{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
774
|
+
'.dsws-pinned-notice-icon{flex:none;width:14px;height:14px;display:inline-flex;align-items:center;',
|
|
775
|
+
'justify-content:center;margin-top:1px}',
|
|
776
|
+
'.dsws-pinned-notice[data-kind="waiting"] .dsws-pinned-notice-icon{color:var(--dsw-alias-state-warn-primary)}',
|
|
777
|
+
'.dsws-pinned-notice[data-kind="finished"] .dsws-pinned-notice-icon{color:var(--dsw-alias-state-success-primary)}',
|
|
778
|
+
'.dsws-pinned-notice[data-kind="test"] .dsws-pinned-notice-icon{color:var(--dsw-alias-state-business-primary)}',
|
|
779
|
+
'.dsws-pinned-notice-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px}',
|
|
780
|
+
'.dsws-pinned-notice-title{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
781
|
+
'.dsws-pinned-notice-body{color:var(--dsw-alias-label-secondary);font-size:11px;line-height:15px;',
|
|
782
|
+
'overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}',
|
|
783
|
+
'.dsws-pinned-notice-close{flex:none;width:18px;height:18px;display:inline-flex;align-items:center;',
|
|
784
|
+
'justify-content:center;padding:0;border:0;border-radius:var(--dsw-radius-sm);background:none;',
|
|
785
|
+
'color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:14px;line-height:18px}',
|
|
786
|
+
'.dsws-pinned-notice-close:hover{color:var(--dsw-alias-label-primary)}',
|
|
787
|
+
'.dsws-pinned-group{margin-top:1px}',
|
|
788
|
+
'.dsws-pinned-group-head{display:flex;align-items:center;gap:6px;box-sizing:border-box;width:100%;height:34px;',
|
|
789
|
+
'margin:0;padding:0 8px;border:0;border-radius:var(--dsw-radius-md);background:none;',
|
|
790
|
+
'color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;line-height:20px;cursor:pointer;',
|
|
791
|
+
'text-align:left;user-select:none}',
|
|
792
|
+
'.dsws-pinned-group-head:hover,.dsws-pinned-group-head:focus-visible{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
793
|
+
'.dsws-pinned-fileglyph{width:16px;height:20px;flex:none;display:inline-flex;align-items:center;',
|
|
794
|
+
'justify-content:center;color:var(--dsw-alias-label-tertiary)}',
|
|
795
|
+
'.dsws-pinned-fileglyph[data-active="true"]{color:var(--dsw-alias-state-business-primary)}',
|
|
796
|
+
'.dsws-pinned-arrow{display:none;width:16px;height:20px;flex:none;align-items:center;justify-content:center;color:var(--dsw-alias-label-tertiary);',
|
|
797
|
+
'transform:rotate(0deg);transition:transform .15s ease}',
|
|
798
|
+
'.dsws-pinned-group[data-expanded="true"] .dsws-pinned-arrow{transform:rotate(90deg)}',
|
|
799
|
+
'.dsws-pinned-group-head:hover .dsws-pinned-fileglyph,.dsws-pinned-group-head:focus-visible .dsws-pinned-fileglyph{display:none}',
|
|
800
|
+
'.dsws-pinned-group-head:hover .dsws-pinned-arrow,.dsws-pinned-group-head:focus-visible .dsws-pinned-arrow{display:inline-flex}',
|
|
801
|
+
'.dsws-pinned-group-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
802
|
+
'.dsws-pinned-group-count{flex:none;color:var(--dsws-alias-label-tertiary);font-size:12px;line-height:17px;',
|
|
803
|
+
'font-variant-numeric:tabular-nums}',
|
|
804
|
+
'.dsws-pinned-row{display:flex;align-items:center;box-sizing:border-box;width:100%;height:32px;',
|
|
805
|
+
'margin:0;padding:0 6px 0 28px;border:0;border-radius:var(--dsw-radius-md);background:none;',
|
|
806
|
+
'color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;line-height:20px;cursor:pointer;',
|
|
807
|
+
'text-align:left;user-select:none}',
|
|
808
|
+
'.dsws-pinned-row:hover{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
809
|
+
'.dsws-pinned-row[data-current="true"]{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
810
|
+
'.dsws-pinned-dotslot{width:16px;height:20px;flex:none;display:inline-flex;align-items:center;',
|
|
811
|
+
'justify-content:center;margin-right:4px}',
|
|
812
|
+
'.dsws-pinned-dot{flex:none;border-radius:50%;box-sizing:border-box}',
|
|
813
|
+
'.dsws-pinned-dot[data-state="warning"]{width:6px;height:6px;background:var(--dsw-alias-state-warn-primary);',
|
|
814
|
+
'box-shadow:0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent)}',
|
|
815
|
+
'.dsws-pinned-dot[data-state="done"]{width:6px;height:6px;background:var(--dsw-alias-state-success-primary);',
|
|
816
|
+
'box-shadow:0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-success-primary) 14%, transparent)}',
|
|
817
|
+
'.dsws-pinned-dot[data-state="ongoing"]{width:10px;height:10px;background-color:var(--dsw-static-deepseek-450);',
|
|
818
|
+
`-webkit-mask-image:${RING};mask-image:${RING};-webkit-mask-size:100% 100%;mask-size:100% 100%;`,
|
|
819
|
+
'-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;animation:dsws-spin 1.5s linear infinite}',
|
|
820
|
+
'.dsws-pinned-row-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
821
|
+
'.dsws-pinned-time{flex:none;margin-left:6px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:17px}',
|
|
822
|
+
'.dsws-pinned-unpin{flex:none;width:22px;height:22px;margin-left:2px;display:inline-flex;',
|
|
823
|
+
'align-items:center;justify-content:center;padding:0;border:0;border-radius:var(--dsw-radius-sm);',
|
|
824
|
+
'background:none;color:var(--dsw-alias-label-tertiary);cursor:pointer}',
|
|
825
|
+
'.dsws-pinned-unpin:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
|
|
826
|
+
'.dsws-pinned-unpin:disabled{opacity:.45;cursor:default}',
|
|
827
|
+
'@media (prefers-reduced-motion:reduce){',
|
|
828
|
+
'.dsws-pinned-dot[data-state="ongoing"]{animation:none;opacity:.7}',
|
|
829
|
+
'.dsws-pinned-chevron,.dsws-pinned-arrow{transition:none}}',
|
|
830
|
+
].join('')
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Diff the status of the pinned conversations against the previous render.
|
|
834
|
+
*
|
|
835
|
+
* Only transitions produce events: the first observation of a session is
|
|
836
|
+
* silent (so reloading the app with a conversation already waiting does not
|
|
837
|
+
* replay old news), repeated renders of the same waiting kind stay quiet,
|
|
838
|
+
* and a kind change (approval → question) is a new event. Finished fires
|
|
839
|
+
* once when a running session stops with its unread completion mark set.
|
|
840
|
+
*
|
|
841
|
+
* @param previous - previous per-session status map.
|
|
842
|
+
* @param sessions - pinned sessions from the current model.
|
|
843
|
+
* @returns the events to deliver plus the map for the next render.
|
|
844
|
+
*/
|
|
845
|
+
function notificationEvents(previous, sessions) {
|
|
846
|
+
const next = new Map()
|
|
847
|
+
const events = []
|
|
848
|
+
for (const session of Array.isArray(sessions) ? sessions : []) {
|
|
849
|
+
const pendingKind = typeof session.pendingKind === 'string' ? session.pendingKind : null
|
|
850
|
+
const running = session.running === true
|
|
851
|
+
const completionUnread = session.completionUnread === true
|
|
852
|
+
const state = { pendingKind, running, completionUnread }
|
|
853
|
+
const prior = previous instanceof Map ? previous.get(session.id) : undefined
|
|
854
|
+
if (prior === undefined) {
|
|
855
|
+
next.set(session.id, state)
|
|
856
|
+
continue
|
|
857
|
+
}
|
|
858
|
+
if (pendingKind !== null && pendingKind !== prior.pendingKind) {
|
|
859
|
+
events.push({ type: 'waiting', session })
|
|
860
|
+
}
|
|
861
|
+
if (completionUnread && !running && (prior.running === true || prior.completionUnread !== true)) {
|
|
862
|
+
events.push({ type: 'finished', session })
|
|
863
|
+
}
|
|
864
|
+
next.set(session.id, state)
|
|
865
|
+
}
|
|
866
|
+
return { events, next }
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** The OS-notification title/body/tag for one diff event. */
|
|
870
|
+
function notificationContent(event) {
|
|
871
|
+
const workspace = typeof event.session.workspace === 'string' && event.session.workspace !== ''
|
|
872
|
+
? event.session.workspace
|
|
873
|
+
: message('activity.unnamed')
|
|
874
|
+
if (event.type === 'waiting') {
|
|
875
|
+
const kind = event.session.pendingKind
|
|
876
|
+
const reasonKey = kind === 'approval'
|
|
877
|
+
? 'notify.waitingReason.approval'
|
|
878
|
+
: kind === 'plan-review'
|
|
879
|
+
? 'notify.waitingReason.planReview'
|
|
880
|
+
: kind === 'question'
|
|
881
|
+
? 'notify.waitingReason.question'
|
|
882
|
+
: 'notify.waitingReason.default'
|
|
883
|
+
return {
|
|
884
|
+
title: message('notify.waiting.title', { title: event.session.title }),
|
|
885
|
+
body: message('notify.waiting.body', { reason: message(reasonKey), workspace }),
|
|
886
|
+
tag: `dsws:${event.session.id}:waiting:${kind ?? 'default'}`,
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
return {
|
|
890
|
+
title: message('notify.finished.title', { title: event.session.title }),
|
|
891
|
+
body: message('notify.finished.body', { workspace }),
|
|
892
|
+
tag: `dsws:${event.session.id}:finished`,
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/** Read the notification preferences without assuming storage access. */
|
|
897
|
+
function readNotificationPrefs() {
|
|
898
|
+
const fallback = { enabled: true, sound: false }
|
|
899
|
+
if (typeof localStorage === 'undefined') return fallback
|
|
900
|
+
try {
|
|
901
|
+
const raw = localStorage.getItem(NOTIFY_STORE_KEY)
|
|
902
|
+
if (typeof raw !== 'string' || raw === '') return fallback
|
|
903
|
+
const parsed = JSON.parse(raw)
|
|
904
|
+
if (parsed === null || typeof parsed !== 'object') return fallback
|
|
905
|
+
return {
|
|
906
|
+
enabled: parsed.enabled !== false,
|
|
907
|
+
sound: parsed.sound === true,
|
|
908
|
+
}
|
|
909
|
+
} catch {
|
|
910
|
+
return fallback
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** Persist notification preferences and return the value for a state update. */
|
|
915
|
+
function writeNotificationPrefs(prefs) {
|
|
916
|
+
if (typeof localStorage !== 'undefined') {
|
|
917
|
+
try {
|
|
918
|
+
localStorage.setItem(NOTIFY_STORE_KEY, JSON.stringify(prefs))
|
|
919
|
+
} catch {
|
|
920
|
+
// Storage may be unavailable; the in-memory preference still applies.
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
return prefs
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** Lazily created audio context for the optional chime. */
|
|
927
|
+
let audioContext
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Play the short two-note chime. Failures are contained: a locked or
|
|
931
|
+
* unsupported audio context must never interfere with the notification.
|
|
932
|
+
*/
|
|
933
|
+
function playChime() {
|
|
934
|
+
try {
|
|
935
|
+
const Ctx = typeof window === 'undefined'
|
|
936
|
+
? undefined
|
|
937
|
+
: window.AudioContext ?? window.webkitAudioContext
|
|
938
|
+
if (typeof Ctx !== 'function') return
|
|
939
|
+
audioContext = audioContext ?? new Ctx()
|
|
940
|
+
if (audioContext.state === 'suspended') audioContext.resume().catch(() => {})
|
|
941
|
+
const start = audioContext.currentTime
|
|
942
|
+
for (const [index, frequency] of [880, 1174.66].entries()) {
|
|
943
|
+
const oscillator = audioContext.createOscillator()
|
|
944
|
+
const gain = audioContext.createGain()
|
|
945
|
+
const at = start + index * 0.08
|
|
946
|
+
oscillator.type = 'sine'
|
|
947
|
+
oscillator.frequency.value = frequency
|
|
948
|
+
gain.gain.setValueAtTime(0.0001, at)
|
|
949
|
+
gain.gain.exponentialRampToValueAtTime(0.06, at + 0.012)
|
|
950
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, at + 0.13)
|
|
951
|
+
oscillator.connect(gain)
|
|
952
|
+
gain.connect(audioContext.destination)
|
|
953
|
+
oscillator.start(at)
|
|
954
|
+
oscillator.stop(at + 0.14)
|
|
955
|
+
}
|
|
956
|
+
} catch {
|
|
957
|
+
// Audio is optional; never surface a failure.
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** Read the browser-local collapse memory without assuming storage access. */
|
|
962
|
+
function readViewState() {
|
|
963
|
+
const fallback = { sectionCollapsed: false, groups: {} }
|
|
964
|
+
if (typeof localStorage === 'undefined') return fallback
|
|
965
|
+
try {
|
|
966
|
+
const raw = localStorage.getItem(VIEW_STORE_KEY)
|
|
967
|
+
if (typeof raw !== 'string' || raw === '') return fallback
|
|
968
|
+
const parsed = JSON.parse(raw)
|
|
969
|
+
if (parsed === null || typeof parsed !== 'object') return fallback
|
|
970
|
+
const groups = parsed.groups !== null && typeof parsed.groups === 'object' ? parsed.groups : {}
|
|
971
|
+
return { sectionCollapsed: parsed.sectionCollapsed === true, groups }
|
|
972
|
+
} catch {
|
|
973
|
+
return fallback
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/** Persist the collapse memory and return the new value for a state update. */
|
|
978
|
+
function writeViewState(state) {
|
|
979
|
+
if (typeof localStorage !== 'undefined') {
|
|
980
|
+
try {
|
|
981
|
+
localStorage.setItem(VIEW_STORE_KEY, JSON.stringify(state))
|
|
982
|
+
} catch {
|
|
983
|
+
// Storage may be unavailable or full; collapsing still works in memory.
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return state
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Insert the portal container before the browser region and keep it there.
|
|
991
|
+
*
|
|
992
|
+
* The container is a sibling of the region inside the sidebar column, so it
|
|
993
|
+
* sits between the panel rows and the Workspaces header without entering
|
|
994
|
+
* the browser's own React tree. A MutationObserver restores the insertion
|
|
995
|
+
* if a shell re-render removes it and mirrors the sidebar's collapsed
|
|
996
|
+
* state so the section disappears with the wide layout.
|
|
997
|
+
* @param marker - the mount's own DOM node, used only to prove React mounted us.
|
|
998
|
+
* @returns the container plus its disposer, or null outside a browser DOM.
|
|
999
|
+
*/
|
|
1000
|
+
function attachPinnedHost(marker) {
|
|
1001
|
+
if (typeof document === 'undefined' || marker === null) return null
|
|
1002
|
+
const region = document.querySelector(REGION_SELECTOR)
|
|
1003
|
+
if (region === null || region.parentElement === null) return null
|
|
1004
|
+
const parent = region.parentElement
|
|
1005
|
+
|
|
1006
|
+
const host = document.createElement('div')
|
|
1007
|
+
host.className = 'dsws-pinned'
|
|
1008
|
+
host.setAttribute(SECTION_ATTR, '')
|
|
1009
|
+
|
|
1010
|
+
const place = () => {
|
|
1011
|
+
const current = document.querySelector(REGION_SELECTOR)
|
|
1012
|
+
if (current === null || current.parentElement === null) return
|
|
1013
|
+
if (host.parentElement !== current.parentElement || host.nextElementSibling !== current) {
|
|
1014
|
+
current.parentElement.insertBefore(host, current)
|
|
1015
|
+
}
|
|
1016
|
+
const collapsed = Array.from(current.parentElement.classList ?? [])
|
|
1017
|
+
.some((token) => token.endsWith('_collapsed'))
|
|
1018
|
+
if (host.getAttribute('data-rail') !== String(collapsed)) {
|
|
1019
|
+
host.setAttribute('data-rail', String(collapsed))
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
place()
|
|
1024
|
+
const observer = new MutationObserver(place)
|
|
1025
|
+
observer.observe(parent, { childList: true, attributes: true, attributeFilter: ['class'] })
|
|
1026
|
+
return {
|
|
1027
|
+
host,
|
|
1028
|
+
dispose() {
|
|
1029
|
+
observer.disconnect()
|
|
1030
|
+
host.remove()
|
|
1031
|
+
},
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* The pinned section. Rendered through the portal; walks the pin set from
|
|
1037
|
+
* the Workspace feed, groups it per Workspace, and unpins through the
|
|
1038
|
+
* Workspace service so the official tree and this section update together.
|
|
1039
|
+
*/
|
|
1040
|
+
function PinnedSection({ model, openSession, unpinSession }) {
|
|
1041
|
+
const [view, setView] = React.useState(readViewState)
|
|
1042
|
+
const [pending, setPending] = React.useState(() => ({}))
|
|
1043
|
+
const [notifyPrefs, setNotifyPrefs] = React.useState(readNotificationPrefs)
|
|
1044
|
+
const [permission, setPermission] = React.useState(
|
|
1045
|
+
() => NOTIFICATION_API === undefined ? 'unsupported' : NOTIFICATION_API.permission,
|
|
1046
|
+
)
|
|
1047
|
+
const notifyStateRef = React.useRef(new Map())
|
|
1048
|
+
const [notices, setNotices] = React.useState([])
|
|
1049
|
+
const noticeTimers = React.useRef(new Map())
|
|
1050
|
+
|
|
1051
|
+
const dismissNotice = (id) => {
|
|
1052
|
+
const timer = noticeTimers.current.get(id)
|
|
1053
|
+
if (timer !== undefined) {
|
|
1054
|
+
clearTimeout(timer)
|
|
1055
|
+
noticeTimers.current.delete(id)
|
|
1056
|
+
}
|
|
1057
|
+
setNotices((previous) => previous.filter((notice) => notice.id !== id))
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
const pushNotice = (notice) => {
|
|
1061
|
+
const id = `${notice.sessionId ?? 'notice'}:${Date.now()}:${Math.random().toString(36).slice(2, 6)}`
|
|
1062
|
+
setNotices((previous) => [...previous.slice(-2), { ...notice, id }])
|
|
1063
|
+
const timer = setTimeout(() => {
|
|
1064
|
+
noticeTimers.current.delete(id)
|
|
1065
|
+
setNotices((previous) => previous.filter((entry) => entry.id !== id))
|
|
1066
|
+
}, 8000)
|
|
1067
|
+
noticeTimers.current.set(id, timer)
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
React.useEffect(() => () => {
|
|
1071
|
+
for (const timer of noticeTimers.current.values()) clearTimeout(timer)
|
|
1072
|
+
noticeTimers.current.clear()
|
|
1073
|
+
}, [])
|
|
1074
|
+
|
|
1075
|
+
// The permission can change outside the renderer (OS settings); re-read
|
|
1076
|
+
// it when the section mounts so the bell state matches reality.
|
|
1077
|
+
React.useEffect(() => {
|
|
1078
|
+
if (NOTIFICATION_API === undefined) return
|
|
1079
|
+
setPermission(NOTIFICATION_API.permission)
|
|
1080
|
+
}, [])
|
|
1081
|
+
|
|
1082
|
+
// Deliver transitions for pinned conversations only. The previous map
|
|
1083
|
+
// advances even while notifications are off, so turning them on cannot
|
|
1084
|
+
// replay history.
|
|
1085
|
+
React.useEffect(() => {
|
|
1086
|
+
const sessions = model.groups.flatMap((group) => group.sessions)
|
|
1087
|
+
const { events, next } = notificationEvents(notifyStateRef.current, sessions)
|
|
1088
|
+
notifyStateRef.current = next
|
|
1089
|
+
if (events.length === 0) return
|
|
1090
|
+
if (!notifyPrefs.enabled) return
|
|
1091
|
+
const focused = typeof document !== 'undefined' && typeof document.hasFocus === 'function'
|
|
1092
|
+
? document.hasFocus()
|
|
1093
|
+
: true
|
|
1094
|
+
let delivered = false
|
|
1095
|
+
for (const event of events) {
|
|
1096
|
+
const session = event.session
|
|
1097
|
+
if (session.current === true && focused) continue
|
|
1098
|
+
const content = notificationContent(event)
|
|
1099
|
+
// In-app notice always (it is the fallback when the OS refuses or
|
|
1100
|
+
// the window is focused); the OS notification only when the window
|
|
1101
|
+
// is in the background and the platform granted permission.
|
|
1102
|
+
pushNotice({
|
|
1103
|
+
sessionId: session.id,
|
|
1104
|
+
kind: event.type,
|
|
1105
|
+
title: content.title,
|
|
1106
|
+
body: content.body,
|
|
1107
|
+
})
|
|
1108
|
+
delivered = true
|
|
1109
|
+
if (!focused && permission === 'granted') {
|
|
1110
|
+
try {
|
|
1111
|
+
const notification = new NOTIFICATION_API(content.title, {
|
|
1112
|
+
body: content.body,
|
|
1113
|
+
tag: content.tag,
|
|
1114
|
+
})
|
|
1115
|
+
notification.onclick = () => {
|
|
1116
|
+
try { window.focus() } catch {}
|
|
1117
|
+
openSession(session.id)
|
|
1118
|
+
try { notification.close() } catch {}
|
|
1119
|
+
}
|
|
1120
|
+
} catch {
|
|
1121
|
+
// A denied or unsupported constructor must not break the section.
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
if (delivered && notifyPrefs.sound) playChime()
|
|
1126
|
+
}, [model, notifyPrefs.enabled, notifyPrefs.sound, permission, openSession])
|
|
1127
|
+
|
|
1128
|
+
const sendTestNotification = (osAllowed = permission === 'granted') => {
|
|
1129
|
+
const title = message('notify.enabled.title')
|
|
1130
|
+
const body = message('notify.enabled.body')
|
|
1131
|
+
pushNotice({ kind: 'test', title, body })
|
|
1132
|
+
if (NOTIFICATION_API !== undefined && osAllowed) {
|
|
1133
|
+
try {
|
|
1134
|
+
new NOTIFICATION_API(title, { body, tag: 'dsws:enabled' })
|
|
1135
|
+
} catch {
|
|
1136
|
+
// Permission may be denied between the check and the constructor.
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const toggleNotifications = () => {
|
|
1142
|
+
if (NOTIFICATION_API !== undefined && permission === 'default') {
|
|
1143
|
+
// First use: ask for the OS permission, then keep notifications on
|
|
1144
|
+
// and show the one-off test notice. A denial still leaves the
|
|
1145
|
+
// in-app notices available.
|
|
1146
|
+
Promise.resolve(NOTIFICATION_API.requestPermission())
|
|
1147
|
+
.then((result) => {
|
|
1148
|
+
setPermission(result)
|
|
1149
|
+
setNotifyPrefs(writeNotificationPrefs({ ...notifyPrefs, enabled: true }))
|
|
1150
|
+
sendTestNotification(result === 'granted')
|
|
1151
|
+
})
|
|
1152
|
+
.catch(() => {
|
|
1153
|
+
setNotifyPrefs(writeNotificationPrefs({ ...notifyPrefs, enabled: true }))
|
|
1154
|
+
sendTestNotification(false)
|
|
1155
|
+
})
|
|
1156
|
+
return
|
|
1157
|
+
}
|
|
1158
|
+
const enabled = !notifyPrefs.enabled
|
|
1159
|
+
setNotifyPrefs(writeNotificationPrefs({ ...notifyPrefs, enabled }))
|
|
1160
|
+
if (enabled) sendTestNotification()
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
const toggleSound = () => {
|
|
1164
|
+
const sound = !notifyPrefs.sound
|
|
1165
|
+
setNotifyPrefs(writeNotificationPrefs({ ...notifyPrefs, sound }))
|
|
1166
|
+
if (sound) playChime()
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
const notificationsOn = notifyPrefs.enabled
|
|
1170
|
+
const bellTitle = permission === 'denied' && notificationsOn
|
|
1171
|
+
? message('notify.blocked')
|
|
1172
|
+
: permission === 'default' && notificationsOn
|
|
1173
|
+
? message('notify.permissionNeeded')
|
|
1174
|
+
: notificationsOn
|
|
1175
|
+
? message('notify.disable')
|
|
1176
|
+
: message('notify.enable')
|
|
1177
|
+
|
|
1178
|
+
const toggleSection = () => {
|
|
1179
|
+
setView((previous) => writeViewState({
|
|
1180
|
+
...previous,
|
|
1181
|
+
sectionCollapsed: !previous.sectionCollapsed,
|
|
1182
|
+
}))
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
const toggleGroup = (key) => {
|
|
1186
|
+
setView((previous) => {
|
|
1187
|
+
const groups = { ...previous.groups }
|
|
1188
|
+
if (groups[key] === true) delete groups[key]
|
|
1189
|
+
else groups[key] = true
|
|
1190
|
+
return writeViewState({ ...previous, groups })
|
|
1191
|
+
})
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
const unpin = (sessionId) => {
|
|
1195
|
+
if (pending[sessionId] === true) return
|
|
1196
|
+
setPending((previous) => ({ ...previous, [sessionId]: true }))
|
|
1197
|
+
Promise.resolve()
|
|
1198
|
+
.then(() => unpinSession(sessionId))
|
|
1199
|
+
.catch(() => {})
|
|
1200
|
+
.finally(() => {
|
|
1201
|
+
setPending((previous) => {
|
|
1202
|
+
const next = { ...previous }
|
|
1203
|
+
delete next[sessionId]
|
|
1204
|
+
return next
|
|
1205
|
+
})
|
|
1206
|
+
})
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
const open = (sessionId) => {
|
|
1210
|
+
openSession(sessionId)
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const sectionOpen = view.sectionCollapsed !== true
|
|
1214
|
+
const rows = []
|
|
1215
|
+
|
|
1216
|
+
rows.push(h(
|
|
1217
|
+
'div',
|
|
1218
|
+
{ key: 'header', className: 'dsws-pinned-headerrow' },
|
|
1219
|
+
h(
|
|
1220
|
+
'button',
|
|
1221
|
+
{
|
|
1222
|
+
type: 'button',
|
|
1223
|
+
className: 'dsws-pinned-header',
|
|
1224
|
+
'aria-expanded': String(sectionOpen),
|
|
1225
|
+
'aria-label': message('pinned.sectionAria'),
|
|
1226
|
+
onClick: toggleSection,
|
|
1227
|
+
},
|
|
1228
|
+
h('span', { className: 'dsws-pinned-glyph' }, pinIcon(14)),
|
|
1229
|
+
h('span', { className: 'dsws-pinned-label' }, message('pinned.title')),
|
|
1230
|
+
h('span', { className: 'dsws-pinned-count' }, String(model.total)),
|
|
1231
|
+
h('span', {
|
|
1232
|
+
className: 'dsws-pinned-chevron',
|
|
1233
|
+
'data-open': sectionOpen ? 'true' : 'false',
|
|
1234
|
+
}, chevronIcon()),
|
|
1235
|
+
),
|
|
1236
|
+
h(
|
|
1237
|
+
'span',
|
|
1238
|
+
{ className: 'dsws-pinned-tools' },
|
|
1239
|
+
h('button', {
|
|
1240
|
+
type: 'button',
|
|
1241
|
+
className: 'dsws-pinned-tool',
|
|
1242
|
+
title: bellTitle,
|
|
1243
|
+
'aria-label': bellTitle,
|
|
1244
|
+
'aria-pressed': String(notificationsOn),
|
|
1245
|
+
'data-on': notificationsOn ? 'true' : 'false',
|
|
1246
|
+
'data-blocked': permission === 'denied' ? 'true' : 'false',
|
|
1247
|
+
onClick: toggleNotifications,
|
|
1248
|
+
}, bellIcon()),
|
|
1249
|
+
h('button', {
|
|
1250
|
+
type: 'button',
|
|
1251
|
+
className: 'dsws-pinned-tool',
|
|
1252
|
+
title: notifyPrefs.sound ? message('notify.soundOn') : message('notify.soundOff'),
|
|
1253
|
+
'aria-label': notifyPrefs.sound ? message('notify.soundOn') : message('notify.soundOff'),
|
|
1254
|
+
'aria-pressed': String(notifyPrefs.sound),
|
|
1255
|
+
'data-on': notifyPrefs.sound ? 'true' : 'false',
|
|
1256
|
+
onClick: toggleSound,
|
|
1257
|
+
}, volumeIcon()),
|
|
1258
|
+
),
|
|
1259
|
+
))
|
|
1260
|
+
|
|
1261
|
+
if (notices.length > 0) {
|
|
1262
|
+
rows.push(h(
|
|
1263
|
+
'div',
|
|
1264
|
+
{
|
|
1265
|
+
key: 'notices',
|
|
1266
|
+
className: 'dsws-pinned-notices',
|
|
1267
|
+
role: 'status',
|
|
1268
|
+
'aria-live': 'polite',
|
|
1269
|
+
},
|
|
1270
|
+
notices.map((notice) => {
|
|
1271
|
+
const clickable = typeof notice.sessionId === 'string'
|
|
1272
|
+
return h(
|
|
1273
|
+
'div',
|
|
1274
|
+
{
|
|
1275
|
+
key: notice.id,
|
|
1276
|
+
className: 'dsws-pinned-notice',
|
|
1277
|
+
'data-kind': notice.kind,
|
|
1278
|
+
role: clickable ? 'button' : undefined,
|
|
1279
|
+
tabIndex: clickable ? 0 : undefined,
|
|
1280
|
+
onClick: clickable
|
|
1281
|
+
? () => { dismissNotice(notice.id); open(notice.sessionId) }
|
|
1282
|
+
: undefined,
|
|
1283
|
+
onKeyDown: clickable
|
|
1284
|
+
? (event) => {
|
|
1285
|
+
if (event.key !== 'Enter' && event.key !== ' ') return
|
|
1286
|
+
event.preventDefault()
|
|
1287
|
+
dismissNotice(notice.id)
|
|
1288
|
+
open(notice.sessionId)
|
|
1289
|
+
}
|
|
1290
|
+
: undefined,
|
|
1291
|
+
},
|
|
1292
|
+
h(
|
|
1293
|
+
'span',
|
|
1294
|
+
{ className: 'dsws-pinned-notice-icon' },
|
|
1295
|
+
notice.kind === 'test'
|
|
1296
|
+
? bellIcon()
|
|
1297
|
+
: h('span', {
|
|
1298
|
+
className: 'dsws-pinned-dot',
|
|
1299
|
+
'data-state': notice.kind === 'waiting' ? 'warning' : 'done',
|
|
1300
|
+
}),
|
|
1301
|
+
),
|
|
1302
|
+
h(
|
|
1303
|
+
'span',
|
|
1304
|
+
{ className: 'dsws-pinned-notice-text' },
|
|
1305
|
+
h('span', { className: 'dsws-pinned-notice-title' }, notice.title),
|
|
1306
|
+
h('span', { className: 'dsws-pinned-notice-body' }, notice.body),
|
|
1307
|
+
),
|
|
1308
|
+
h('button', {
|
|
1309
|
+
type: 'button',
|
|
1310
|
+
className: 'dsws-pinned-notice-close',
|
|
1311
|
+
'aria-label': message('notify.dismiss'),
|
|
1312
|
+
onClick: (event) => { event.stopPropagation(); dismissNotice(notice.id) },
|
|
1313
|
+
}, '\u00d7'),
|
|
1314
|
+
)
|
|
1315
|
+
}),
|
|
1316
|
+
))
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
if (sectionOpen) {
|
|
1320
|
+
for (const group of model.groups) {
|
|
1321
|
+
const expanded = view.groups[group.key] !== true
|
|
1322
|
+
const title = group.title !== '' ? group.title : message('activity.unnamed')
|
|
1323
|
+
const groupRows = []
|
|
1324
|
+
groupRows.push(h(
|
|
1325
|
+
'button',
|
|
1326
|
+
{
|
|
1327
|
+
key: 'head',
|
|
1328
|
+
type: 'button',
|
|
1329
|
+
className: 'dsws-pinned-group-head',
|
|
1330
|
+
'aria-expanded': String(expanded),
|
|
1331
|
+
'aria-label': message('pinned.groupAria', { workspace: title }),
|
|
1332
|
+
onClick: () => toggleGroup(group.key),
|
|
1333
|
+
},
|
|
1334
|
+
h('span', { className: 'dsws-pinned-fileglyph', 'data-active': group.containsCurrent ? 'true' : 'false' }, folderIcon()),
|
|
1335
|
+
h('span', { className: 'dsws-pinned-arrow' }, chevronIcon()),
|
|
1336
|
+
h('span', { className: 'dsws-pinned-group-title' }, title),
|
|
1337
|
+
h('span', { className: 'dsws-pinned-group-count' }, String(group.count)),
|
|
1338
|
+
))
|
|
1339
|
+
if (expanded) {
|
|
1340
|
+
for (const session of group.sessions) {
|
|
1341
|
+
groupRows.push(h(
|
|
1342
|
+
'div',
|
|
1343
|
+
{
|
|
1344
|
+
key: session.id,
|
|
1345
|
+
className: 'dsws-pinned-row',
|
|
1346
|
+
role: 'treeitem',
|
|
1347
|
+
tabIndex: 0,
|
|
1348
|
+
'data-current': session.current ? 'true' : 'false',
|
|
1349
|
+
'aria-current': session.current ? 'true' : undefined,
|
|
1350
|
+
title: session.title,
|
|
1351
|
+
onClick: () => open(session.id),
|
|
1352
|
+
onKeyDown: (event) => {
|
|
1353
|
+
if (event.key !== 'Enter' && event.key !== ' ') return
|
|
1354
|
+
event.preventDefault()
|
|
1355
|
+
open(session.id)
|
|
1356
|
+
},
|
|
1357
|
+
},
|
|
1358
|
+
h(
|
|
1359
|
+
'span',
|
|
1360
|
+
{ className: 'dsws-pinned-dotslot' },
|
|
1361
|
+
session.mark === undefined
|
|
1362
|
+
? null
|
|
1363
|
+
: h('span', { className: 'dsws-pinned-dot', 'data-state': session.mark }),
|
|
1364
|
+
),
|
|
1365
|
+
h('span', { className: 'dsws-pinned-row-title' }, session.title),
|
|
1366
|
+
session.time === '' ? null : h('span', { className: 'dsws-pinned-time' }, session.time),
|
|
1367
|
+
h(
|
|
1368
|
+
'button',
|
|
1369
|
+
{
|
|
1370
|
+
type: 'button',
|
|
1371
|
+
className: 'dsws-pinned-unpin',
|
|
1372
|
+
title: message('pinned.unpin'),
|
|
1373
|
+
'aria-label': message('pinned.unpinAria', { title: session.title }),
|
|
1374
|
+
disabled: pending[session.id] === true,
|
|
1375
|
+
onClick: (event) => {
|
|
1376
|
+
event.stopPropagation()
|
|
1377
|
+
unpin(session.id)
|
|
1378
|
+
},
|
|
1379
|
+
},
|
|
1380
|
+
pinIcon(14),
|
|
1381
|
+
),
|
|
1382
|
+
))
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
rows.push(h(
|
|
1386
|
+
'div',
|
|
1387
|
+
{
|
|
1388
|
+
key: group.key === '' ? 'ungrouped' : group.key,
|
|
1389
|
+
className: 'dsws-pinned-group',
|
|
1390
|
+
'data-expanded': expanded ? 'true' : 'false',
|
|
1391
|
+
role: 'group',
|
|
1392
|
+
},
|
|
1393
|
+
groupRows,
|
|
1394
|
+
))
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
return h('div', {
|
|
1399
|
+
className: 'dsws-pinned-inner',
|
|
1400
|
+
role: 'region',
|
|
1401
|
+
'aria-label': message('pinned.title'),
|
|
1402
|
+
}, rows)
|
|
1403
|
+
}
|
|
1404
|
+
|
|
446
1405
|
/**
|
|
447
1406
|
* Read one standard selector hook, tolerating an assembly without it.
|
|
448
1407
|
* @param hook - the standard hook, possibly absent.
|
|
@@ -454,20 +1413,35 @@ window.__ModuleLoader__.load({
|
|
|
454
1413
|
return typeof hook === 'function' ? hook(selector) : selector(fallback)
|
|
455
1414
|
}
|
|
456
1415
|
|
|
457
|
-
|
|
1416
|
+
/** No-op for an absent navigation service. */
|
|
1417
|
+
function noop() {}
|
|
1418
|
+
|
|
1419
|
+
/** No-op pin resolver for an assembly without the Workspace service. */
|
|
1420
|
+
function noopAsync() {
|
|
1421
|
+
return Promise.resolve()
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
const EMPTY_WORKSPACES = { items: [], archivedSessionIds: [], pinnedSessionIds: [] }
|
|
458
1425
|
const EMPTY_SESSIONS = { byId: {} }
|
|
459
1426
|
const EMPTY_STATUSES = new Map()
|
|
460
1427
|
|
|
461
1428
|
/**
|
|
462
|
-
* The slot entry
|
|
463
|
-
*
|
|
464
|
-
* the
|
|
465
|
-
* @
|
|
466
|
-
* @returns the style + hidden description, or null when nothing is marked.
|
|
1429
|
+
* The slot entry: publishes the stylesheet, the hidden activity
|
|
1430
|
+
* description, the portal anchor, and the pinned section portal.
|
|
1431
|
+
* @param props - standard selector hooks plus the injected management actions.
|
|
1432
|
+
* @returns the style/anchor tree and, once attached, the portal.
|
|
467
1433
|
*/
|
|
468
|
-
function WorkspaceStatus({
|
|
1434
|
+
function WorkspaceStatus({
|
|
1435
|
+
useWorkspaces,
|
|
1436
|
+
useSessions,
|
|
1437
|
+
useSessionStatus,
|
|
1438
|
+
externalPinSession,
|
|
1439
|
+
externalUnpinSession,
|
|
1440
|
+
externalOpenSession,
|
|
1441
|
+
}) {
|
|
469
1442
|
const items = useStandard(useWorkspaces, (state) => state?.items, EMPTY_WORKSPACES)
|
|
470
1443
|
const archived = useStandard(useWorkspaces, (state) => state?.archivedSessionIds, EMPTY_WORKSPACES)
|
|
1444
|
+
const pinned = useStandard(useWorkspaces, (state) => state?.pinnedSessionIds, EMPTY_WORKSPACES)
|
|
471
1445
|
const byId = useStandard(useSessions, (state) => state?.byId, EMPTY_SESSIONS)
|
|
472
1446
|
const statuses = useStandard(useSessionStatus, (state) => state, EMPTY_STATUSES)
|
|
473
1447
|
|
|
@@ -484,27 +1458,103 @@ window.__ModuleLoader__.load({
|
|
|
484
1458
|
[items, byId, statuses, archived, watchRef],
|
|
485
1459
|
)
|
|
486
1460
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
React.Fragment,
|
|
491
|
-
null,
|
|
492
|
-
React.createElement('style', { 'data-plugin-css': STYLE_ID }, BASE_CSS + rowCss(activity)),
|
|
493
|
-
React.createElement('span', { className: SR_CLASS }, describe(activity)),
|
|
1461
|
+
const pinnedModel = React.useMemo(
|
|
1462
|
+
() => buildPinnedView(pinned, items, byId, statuses, archived, Date.now()),
|
|
1463
|
+
[pinned, items, byId, statuses, archived],
|
|
494
1464
|
)
|
|
1465
|
+
|
|
1466
|
+
const markerRef = React.useRef(null)
|
|
1467
|
+
const [portalHost, setPortalHost] = React.useState(undefined)
|
|
1468
|
+
React.useEffect(() => {
|
|
1469
|
+
const attached = attachPinnedHost(markerRef.current)
|
|
1470
|
+
if (attached === null) return undefined
|
|
1471
|
+
setPortalHost(attached.host)
|
|
1472
|
+
return () => { attached.dispose() }
|
|
1473
|
+
}, [])
|
|
1474
|
+
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
const cssText = BASE_CSS + PINNED_CSS + rowCss(activity)
|
|
1480
|
+
+ (portalHost === undefined ? '' : hideCss(pinnedModel.ids))
|
|
1481
|
+
const children = [h('style', { key: 'style', 'data-plugin-css': STYLE_ID }, cssText)]
|
|
1482
|
+
|
|
1483
|
+
if (activity.marked > 0) {
|
|
1484
|
+
children.push(h('span', { key: 'activity', className: SR_CLASS }, describe(activity)))
|
|
1485
|
+
}
|
|
1486
|
+
children.push(h('span', {
|
|
1487
|
+
key: 'anchor', ref: markerRef, className: 'dsws-anchor', 'aria-hidden': 'true',
|
|
1488
|
+
}))
|
|
1489
|
+
|
|
1490
|
+
if (portalHost !== undefined && pinnedModel.total > 0) {
|
|
1491
|
+
children.push(ReactDOM.createPortal(
|
|
1492
|
+
h(PinnedSection, {
|
|
1493
|
+
model: pinnedModel,
|
|
1494
|
+
openSession: typeof externalOpenSession === 'function' ? externalOpenSession : noop,
|
|
1495
|
+
unpinSession: typeof externalUnpinSession === 'function' ? externalUnpinSession : noopAsync,
|
|
1496
|
+
}),
|
|
1497
|
+
portalHost,
|
|
1498
|
+
ENTRY_ID,
|
|
1499
|
+
))
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
return h(React.Fragment, null, children)
|
|
495
1503
|
}
|
|
496
1504
|
|
|
497
1505
|
return {
|
|
498
1506
|
name: 'workspace-status',
|
|
499
1507
|
inject: ['slots'],
|
|
500
1508
|
apply(ctx) {
|
|
1509
|
+
// Services are read lazily: the Workspace and ui-workspace client
|
|
1510
|
+
// halves may activate after this entry registers, and pin/open must
|
|
1511
|
+
// still resolve once they exist.
|
|
1512
|
+
const service = (name) => {
|
|
1513
|
+
try {
|
|
1514
|
+
return ctx.get(name)
|
|
1515
|
+
} catch {
|
|
1516
|
+
return undefined
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
501
1519
|
ctx.slots.inject(SLOT, () => ctx.slots.register(
|
|
502
|
-
{
|
|
1520
|
+
{
|
|
1521
|
+
name: SLOT,
|
|
1522
|
+
id: ENTRY_ID,
|
|
1523
|
+
order: ENTRY_ORDER,
|
|
1524
|
+
inject: () => ({
|
|
1525
|
+
externalPinSession: (sessionId) => {
|
|
1526
|
+
const workspaces = service('workspaces')
|
|
1527
|
+
return workspaces === undefined ? noopAsync() : workspaces.pinSession(sessionId)
|
|
1528
|
+
},
|
|
1529
|
+
externalUnpinSession: (sessionId) => {
|
|
1530
|
+
const workspaces = service('workspaces')
|
|
1531
|
+
return workspaces === undefined ? noopAsync() : workspaces.unpinSession(sessionId)
|
|
1532
|
+
},
|
|
1533
|
+
externalOpenSession: (sessionId) => {
|
|
1534
|
+
const uiWorkspace = service('uiWorkspace')
|
|
1535
|
+
if (uiWorkspace !== undefined) uiWorkspace.openSession(sessionId)
|
|
1536
|
+
},
|
|
1537
|
+
}),
|
|
1538
|
+
},
|
|
503
1539
|
WorkspaceStatus,
|
|
504
1540
|
))
|
|
505
1541
|
},
|
|
506
1542
|
/** Exposed for the package's own tests; the Loader ignores it. */
|
|
507
|
-
__test: {
|
|
1543
|
+
__test: {
|
|
1544
|
+
deriveActivity,
|
|
1545
|
+
rowCss,
|
|
1546
|
+
describe,
|
|
1547
|
+
quote,
|
|
1548
|
+
relativeTime,
|
|
1549
|
+
buildPinnedView,
|
|
1550
|
+
hideCss,
|
|
1551
|
+
sessionMark,
|
|
1552
|
+
sessionFacts,
|
|
1553
|
+
notificationEvents,
|
|
1554
|
+
notificationContent,
|
|
1555
|
+
WorkspaceStatus,
|
|
1556
|
+
FINISHED_WINDOW_MS,
|
|
1557
|
+
},
|
|
508
1558
|
}
|
|
509
1559
|
},
|
|
510
1560
|
})
|