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/client.js CHANGED
@@ -1,39 +1,47 @@
1
1
  /**
2
2
  * Browser half of workspace-status.
3
3
  *
4
- * The sidebar draws a status dot on every Session row but nothing on the
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
- * The entry mounts in the additive `sidebar.footer.action` seat, which is
14
- * mounted for the whole browsing region even when no Session exists. It shows
15
- * no control of its own: it publishes the row rules plus a visually hidden
16
- * description of the same state for readers who cannot see the mark.
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 + archive set), `useSessions` (summaries, including
20
- * subagent rows) and `useSessionStatus` (running / pendingInteraction /
21
- * completionUnread). The precedence mirrors the shipped Session row:
22
- * waiting for the user outranks running, which outranks the finished
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/rows.css'
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 mark carries no markup of
60
- * its own (the generated row rules draw it), so this sheet only has to
61
- * keep the description out of the layout.
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
- * only copy this package produces is a hidden description, so the built-in
71
- * dictionaries are enough and the plugin keeps working without the locale
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 group key for a double-quoted CSS attribute selector.
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
- * so the row would lose its mark silently. Each one is written as a hex
137
- * escape with a trailing space, which stops a following hex digit being
138
- * absorbed. Host Workspace ids are UUIDs, so this is a correctness guard,
139
- * not a path expected in practice.
140
- * @param value - raw group key.
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 2px-equivalent stroke with round caps and a faint
160
- * track, matching the geometry of the product's own running indicator.
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
- /** Parent Session id → direct subagent child ids. */
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
- const EMPTY_WORKSPACES = { items: [], archivedSessionIds: [] }
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. It shows no control of its own: it publishes the row
463
- * rules, plus a description of the same state for readers who cannot see
464
- * the mark.
465
- * @param props - standard selector hooks.
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({ useWorkspaces, useSessions, useSessionStatus }) {
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
- if (activity.marked === 0) return null
488
-
489
- return React.createElement(
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
- { name: SLOT, id: ENTRY_ID, order: ENTRY_ORDER },
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: { deriveActivity, rowCss, describe, quote, WorkspaceStatus, FINISHED_WINDOW_MS },
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
  })