@anionex/dsh-pinned-sessions 0.1.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.
@@ -0,0 +1,242 @@
1
+ import { Pin as PinNode, PinOff as PinOffNode, type IconNode } from 'lucide'
2
+
3
+ export const SIDEBAR_SLOT_SELECTOR = '[data-slot="sidebar.workspaces"]'
4
+ export const PINNED_HOST_ATTRIBUTE = 'data-dsh-pinned-sessions-host'
5
+ export const MENU_HOST_ATTRIBUTE = 'data-dsh-pinned-session-menu-host'
6
+ export const MENU_OWNER_ATTRIBUTE = 'data-dsh-pinned-session-menu'
7
+
8
+ export interface SessionsForCapture {
9
+ readonly list: {
10
+ getSnapshot(): { readonly current?: string | undefined }
11
+ }
12
+ open(sessionId: string): void
13
+ }
14
+
15
+ export interface SessionActionTarget {
16
+ readonly button: HTMLButtonElement
17
+ readonly row: HTMLElement
18
+ }
19
+
20
+ export interface SessionMenuTarget {
21
+ readonly host: HTMLDivElement
22
+ readonly menu: HTMLElement
23
+ readonly sessionId: string
24
+ readonly button: HTMLButtonElement
25
+ readonly icon: HTMLSpanElement
26
+ readonly label: HTMLSpanElement
27
+ }
28
+
29
+ export interface TriggerRect {
30
+ readonly top: number
31
+ readonly right: number
32
+ readonly bottom: number
33
+ readonly left: number
34
+ readonly width: number
35
+ readonly height: number
36
+ }
37
+
38
+ function elementFromTarget(target: EventTarget | null): Element | null {
39
+ return target !== null && (target as Node).nodeType === 1 ? target as Element : null
40
+ }
41
+
42
+ /** Identify a native ellipsis trigger inside a real Session row. */
43
+ export function findSessionActionTarget(target: EventTarget | null): SessionActionTarget | null {
44
+ const element = elementFromTarget(target)
45
+ const button = element?.closest('button')
46
+ if (!(button instanceof HTMLButtonElement)) return null
47
+ const row = button.closest<HTMLElement>('[role="treeitem"][aria-selected]')
48
+ if (row === null || row === button) return null
49
+ if (row.closest(SIDEBAR_SLOT_SELECTOR) === null) return null
50
+ if (row.closest(`[${PINNED_HOST_ATTRIBUTE}]`) !== null) return null
51
+ return { button, row }
52
+ }
53
+
54
+ /**
55
+ * Ask the native row closure for its Session ID without changing selection.
56
+ * The Workspace browser's row callback calls sessions.open(node.id), so a
57
+ * synchronous, verified interceptor captures the exact ID and is restored
58
+ * before the user's original ellipsis click continues.
59
+ */
60
+ export function captureSessionId(row: HTMLElement, sessions: SessionsForCapture): string | null {
61
+ const hadOwn = Object.prototype.hasOwnProperty.call(sessions, 'open')
62
+ const ownDescriptor = hadOwn ? Object.getOwnPropertyDescriptor(sessions, 'open') : undefined
63
+ const original = sessions.open
64
+ let captured: string | null = null
65
+ const interceptor = (sessionId: string): void => {
66
+ if (typeof sessionId === 'string' && sessionId.length > 0) captured = sessionId
67
+ }
68
+
69
+ try {
70
+ Object.defineProperty(sessions, 'open', {
71
+ configurable: true,
72
+ enumerable: ownDescriptor?.enumerable ?? false,
73
+ writable: true,
74
+ value: interceptor,
75
+ })
76
+ if (sessions.open !== interceptor) return null
77
+ row.click()
78
+ } catch {
79
+ return null
80
+ } finally {
81
+ try {
82
+ if (hadOwn && ownDescriptor !== undefined) Object.defineProperty(sessions, 'open', ownDescriptor)
83
+ else delete (sessions as { open?: SessionsForCapture['open'] }).open
84
+ } catch {
85
+ try {
86
+ Object.defineProperty(sessions, 'open', {
87
+ configurable: true,
88
+ writable: true,
89
+ value: original,
90
+ })
91
+ } catch {
92
+ // The verified patch path is configurable; this is a defensive fallback.
93
+ }
94
+ }
95
+ }
96
+ return captured
97
+ }
98
+
99
+ /** Insert the pinned region immediately after the native Workspace header. */
100
+ export function ensurePinnedHost(doc: Document): HTMLDivElement | null {
101
+ const slot = doc.querySelector<HTMLElement>(SIDEBAR_SLOT_SELECTOR)
102
+ const root = slot?.firstElementChild
103
+ if (!(root instanceof HTMLElement)) return null
104
+ const existing = root.querySelector<HTMLDivElement>(`:scope > [${PINNED_HOST_ATTRIBUTE}]`)
105
+ if (existing !== null) return existing
106
+ const header = root.firstElementChild
107
+ if (header === null) return null
108
+ const host = doc.createElement('div')
109
+ host.setAttribute(PINNED_HOST_ATTRIBUTE, '')
110
+ header.insertAdjacentElement('afterend', host)
111
+ return host
112
+ }
113
+
114
+ /** List direct-body portal menus so one trigger can exclude every pre-existing menu. */
115
+ export function listPortalMenus(doc: Document): readonly HTMLElement[] {
116
+ return [...doc.body.children].filter((node): node is HTMLElement => (
117
+ node instanceof HTMLElement && node.getAttribute('role') === 'menu'
118
+ ))
119
+ }
120
+
121
+ function belongsToTrigger(menu: HTMLElement, trigger: TriggerRect | undefined): boolean {
122
+ if (trigger === undefined || trigger.width === 0 || trigger.height === 0) return true
123
+ const menuRect = menu.getBoundingClientRect()
124
+ if (menuRect.width === 0 || menuRect.height === 0) return true
125
+ const horizontalGap = Math.min(
126
+ Math.abs(menuRect.left - trigger.left),
127
+ Math.abs(menuRect.right - trigger.right),
128
+ )
129
+ const verticalGap = Math.max(
130
+ 0,
131
+ menuRect.top - trigger.bottom,
132
+ trigger.top - menuRect.bottom,
133
+ )
134
+ return horizontalGap <= 64 && verticalGap <= 32
135
+ }
136
+
137
+ /** Find only a new, geometrically associated portal menu after one trigger click. */
138
+ export function findUnclaimedPortalMenu(
139
+ doc: Document,
140
+ excluded: ReadonlySet<HTMLElement> = new Set(),
141
+ trigger?: TriggerRect,
142
+ ): HTMLElement | null {
143
+ const menus = listPortalMenus(doc).filter(menu => (
144
+ !excluded.has(menu)
145
+ && !menu.hasAttribute(MENU_OWNER_ATTRIBUTE)
146
+ && belongsToTrigger(menu, trigger)
147
+ ))
148
+ return menus.at(-1) ?? null
149
+ }
150
+
151
+ function createLucideIcon(doc: Document, node: IconNode, name: string): SVGSVGElement {
152
+ const [, attributes, children = []] = node
153
+ const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg')
154
+ for (const [key, value] of Object.entries(attributes)) svg.setAttribute(key, String(value))
155
+ svg.setAttribute('width', '16')
156
+ svg.setAttribute('height', '16')
157
+ svg.setAttribute('stroke-width', '1.8')
158
+ svg.setAttribute('class', `lucide lucide-${name}`)
159
+ svg.setAttribute('aria-hidden', 'true')
160
+ for (const [tag, childAttributes] of children) {
161
+ const child = doc.createElementNS('http://www.w3.org/2000/svg', tag)
162
+ for (const [key, value] of Object.entries(childAttributes)) child.setAttribute(key, String(value))
163
+ svg.appendChild(child)
164
+ }
165
+ return svg
166
+ }
167
+
168
+ /** Update the unmanaged item without moving it into the plugin's React tree. */
169
+ export function updateSessionMenuItem(target: SessionMenuTarget, pinned: boolean, label: string): void {
170
+ target.button.setAttribute('aria-label', label)
171
+ target.label.textContent = label
172
+ target.icon.replaceChildren(createLucideIcon(
173
+ target.menu.ownerDocument,
174
+ pinned ? PinOffNode : PinNode,
175
+ pinned ? 'pin-off' : 'pin',
176
+ ))
177
+ }
178
+
179
+ /** Add an unmanaged native-styled row before the Session menu's archive tail. */
180
+ export function attachSessionMenuHost(menu: HTMLElement, sessionId: string): SessionMenuTarget | null {
181
+ if (menu.hasAttribute(MENU_OWNER_ATTRIBUTE)) return null
182
+ const viewport = [...menu.children].find((node): node is HTMLElement => (
183
+ node instanceof HTMLElement && node.getAttribute('role') === 'presentation'
184
+ ))
185
+ if (viewport === undefined) return null
186
+ const wrappers = [...viewport.children].filter((node): node is HTMLElement => (
187
+ node instanceof HTMLElement
188
+ && node.querySelector(':scope > button[role="menuitem"]') instanceof HTMLButtonElement
189
+ ))
190
+ if (wrappers.length < 3 || wrappers.length > 4) return null
191
+ const templateButton = wrappers[0]?.querySelector<HTMLButtonElement>(':scope > button[role="menuitem"]')
192
+ if (templateButton === null || templateButton === undefined) return null
193
+ const spans = [...templateButton.children].filter((node): node is HTMLSpanElement => node instanceof HTMLSpanElement)
194
+ if (spans.length < 2) return null
195
+
196
+ const doc = menu.ownerDocument
197
+ const host = doc.createElement('div')
198
+ host.className = wrappers[0]?.className ?? ''
199
+ host.setAttribute(MENU_HOST_ATTRIBUTE, '')
200
+ const button = doc.createElement('button')
201
+ button.type = 'button'
202
+ button.setAttribute('role', 'menuitem')
203
+ button.className = `${templateButton.className} dsh-pins-menu-button`
204
+ const icon = doc.createElement('span')
205
+ icon.className = spans[0]?.className ?? ''
206
+ icon.setAttribute('aria-hidden', 'true')
207
+ const label = doc.createElement('span')
208
+ label.className = spans.at(-1)?.className ?? ''
209
+ button.append(icon, label)
210
+ host.appendChild(button)
211
+ viewport.insertBefore(host, wrappers[2] ?? null)
212
+ menu.setAttribute(MENU_OWNER_ATTRIBUTE, sessionId)
213
+
214
+ const target = { host, menu, sessionId, button, icon, label }
215
+ const view = doc.defaultView
216
+ if (view !== null) view.dispatchEvent(new view.Event('resize'))
217
+ return target
218
+ }
219
+
220
+ /** Restore useful keyboard focus after a pinned row removes itself. */
221
+ export function focusAfterPinnedRemoval(doc: Document, host: HTMLElement | null, removedIndex: number): boolean {
222
+ const remaining = host === null ? [] : [...host.querySelectorAll<HTMLButtonElement>('.dsh-pins-open')]
223
+ const next = remaining[Math.min(removedIndex, remaining.length - 1)]
224
+ if (next !== undefined) {
225
+ next.focus({ preventScroll: true })
226
+ return true
227
+ }
228
+ const nativeRow = doc.querySelector<HTMLElement>(`${SIDEBAR_SLOT_SELECTOR} [role="treeitem"][aria-selected="true"]`)
229
+ ?? doc.querySelector<HTMLElement>(`${SIDEBAR_SLOT_SELECTOR} [role="treeitem"]`)
230
+ if (nativeRow === null) return false
231
+ const hadTabIndex = nativeRow.hasAttribute('tabindex')
232
+ if (!hadTabIndex) nativeRow.tabIndex = -1
233
+ nativeRow.focus({ preventScroll: true })
234
+ if (doc.activeElement === nativeRow) return true
235
+ if (!hadTabIndex) nativeRow.removeAttribute('tabindex')
236
+ return false
237
+ }
238
+
239
+ export function removeBridgeArtifacts(doc: Document): void {
240
+ for (const node of doc.querySelectorAll(`[${PINNED_HOST_ATTRIBUTE}], [${MENU_HOST_ATTRIBUTE}]`)) node.remove()
241
+ for (const menu of doc.querySelectorAll(`[${MENU_OWNER_ATTRIBUTE}]`)) menu.removeAttribute(MENU_OWNER_ATTRIBUTE)
242
+ }
@@ -0,0 +1,445 @@
1
+ import {
2
+ useEffect,
3
+ useLayoutEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ useSyncExternalStore,
8
+ type ReactNode,
9
+ } from 'react'
10
+ import { Pin, X } from 'lucide-react'
11
+ import { createPortal } from 'react-dom'
12
+ import {
13
+ attachSessionMenuHost,
14
+ captureSessionId,
15
+ ensurePinnedHost,
16
+ findSessionActionTarget,
17
+ findUnclaimedPortalMenu,
18
+ focusAfterPinnedRemoval,
19
+ listPortalMenus,
20
+ MENU_OWNER_ATTRIBUTE,
21
+ removeBridgeArtifacts,
22
+ SIDEBAR_SLOT_SELECTOR,
23
+ updateSessionMenuItem,
24
+ type SessionMenuTarget,
25
+ type TriggerRect,
26
+ type SessionsForCapture,
27
+ } from './dom-bridge.js'
28
+ import { PIN_STORAGE_KEY, PinStore } from './pin-store.js'
29
+ import { sessionDisplayTitle } from './presentation.js'
30
+
31
+ interface SessionSummaryLike {
32
+ readonly id: string
33
+ readonly title?: string | undefined
34
+ readonly cwd?: string | undefined
35
+ readonly blank?: boolean | undefined
36
+ readonly running?: boolean | undefined
37
+ readonly completed?: boolean | undefined
38
+ }
39
+
40
+ interface SessionsSnapshotLike {
41
+ readonly ids: readonly string[]
42
+ readonly byId: Readonly<Record<string, SessionSummaryLike | undefined>>
43
+ readonly current?: string | undefined
44
+ readonly phase: string
45
+ }
46
+
47
+ interface WorkspaceLike {
48
+ readonly workspaceId: string
49
+ readonly title: string
50
+ readonly sessionIds: readonly string[]
51
+ }
52
+
53
+ interface WorkspacesSnapshotLike {
54
+ readonly items: readonly WorkspaceLike[]
55
+ readonly archivedSessionIds: readonly string[]
56
+ readonly phase: string
57
+ }
58
+
59
+ type SelectorHook<T> = <S>(selector: (snapshot: T) => S, equal?: (left: S, right: S) => boolean) => S
60
+ type Translate = (key: string, params?: Readonly<Record<string, unknown>>) => string
61
+
62
+ interface SlotsLike {
63
+ inject(name: string, install: () => unknown): void
64
+ register<I, P>(
65
+ entry: {
66
+ readonly name: string
67
+ readonly id: string
68
+ readonly order?: number
69
+ readonly locale?: string
70
+ readonly inject?: () => I
71
+ },
72
+ component: (props: P) => ReactNode,
73
+ ): () => void
74
+ }
75
+
76
+ interface LocaleLike {
77
+ register(namespace: string, dictionaries: Readonly<Record<string, Readonly<Record<string, string>>>>): () => void
78
+ }
79
+
80
+ interface ClientContextLike {
81
+ readonly slots: SlotsLike
82
+ readonly sessions: SessionsForCapture
83
+ readonly locale: LocaleLike
84
+ effect(setup: () => (() => void), label?: string): unknown
85
+ }
86
+
87
+ interface BridgeProps {
88
+ readonly store: PinStore
89
+ readonly sessions: SessionsForCapture
90
+ readonly useSessions: SelectorHook<SessionsSnapshotLike>
91
+ readonly useWorkspaces: SelectorHook<WorkspacesSnapshotLike>
92
+ readonly t: Translate
93
+ }
94
+
95
+ interface PinIconProps {
96
+ readonly size?: number
97
+ }
98
+
99
+ interface PendingMenuRequest {
100
+ readonly sessionId: string
101
+ readonly createdAt: number
102
+ readonly existingMenus: ReadonlySet<HTMLElement>
103
+ readonly triggerRect: TriggerRect
104
+ }
105
+
106
+ interface ActiveMenuBinding {
107
+ readonly target: SessionMenuTarget
108
+ readonly onClick: (event: MouseEvent) => void
109
+ }
110
+
111
+ const NS = 'pinnedSessions'
112
+ const STYLE_ID = '@anionex/dsh-pinned-sessions'
113
+ const MENU_ASSOCIATION_MS = 2_000
114
+
115
+ const zh = {
116
+ 'menu.pin': '置顶会话',
117
+ 'menu.unpin': '取消置顶',
118
+ 'section.label': '置顶会话',
119
+ 'session.open': '打开置顶会话“{name}”',
120
+ 'session.unpin': '取消置顶“{name}”',
121
+ 'workspace.ungrouped': '未分组',
122
+ }
123
+
124
+ const en = {
125
+ 'menu.pin': 'Pin session',
126
+ 'menu.unpin': 'Unpin session',
127
+ 'section.label': 'Pinned sessions',
128
+ 'session.open': 'Open pinned session “{name}”',
129
+ 'session.unpin': 'Unpin “{name}”',
130
+ 'workspace.ungrouped': 'Ungrouped',
131
+ }
132
+
133
+ const styles = `
134
+ [${'data-dsh-pinned-sessions-host'}]{box-sizing:border-box;min-width:0;flex:none}
135
+ .dsh-pins-section{box-sizing:border-box;min-width:0;margin:0 4px 6px;padding:0 0 6px;border-bottom:1px solid var(--dsw-alias-border-l1)}
136
+ .dsh-pins-header{display:flex;align-items:center;gap:6px;height:28px;padding:0 8px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;font-weight:500}
137
+ .dsh-pins-header svg{flex:none;color:var(--dsw-alias-state-business-primary)}
138
+ .dsh-pins-list{box-sizing:border-box;max-height:192px;min-width:0;margin:0;padding:0;overflow-y:auto;overscroll-behavior:contain;list-style:none}
139
+ .dsh-pins-row{box-sizing:border-box;display:flex;align-items:center;gap:0;height:32px;min-width:0;border-radius:6px;color:var(--dsw-alias-label-primary)}
140
+ .dsh-pins-row:hover,.dsh-pins-row[data-current="true"]{background:var(--dsw-alias-interactive-bg-hover)}
141
+ .dsh-pins-open{box-sizing:border-box;display:flex;align-items:center;gap:6px;min-width:0;height:32px;padding:0 4px 0 8px;border:0;background:transparent;color:inherit;cursor:pointer;flex:1;text-align:left}
142
+ .dsh-pins-open:focus-visible,.dsh-pins-remove:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:-2px}
143
+ .dsh-pins-open>svg{flex:none;color:var(--dsw-alias-state-business-primary)}
144
+ .dsh-pins-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px;line-height:20px;flex:1}
145
+ .dsh-pins-workspace{max-width:34%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:18px;flex:none}
146
+ .dsh-pins-remove{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;margin-right:3px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;opacity:0;flex:none}
147
+ .dsh-pins-row:hover .dsh-pins-remove,.dsh-pins-remove:focus-visible{opacity:1}
148
+ .dsh-pins-remove:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
149
+ .dsh-pins-menu-button{letter-spacing:0}
150
+ @media (max-width:767px){.dsh-pins-workspace{display:none}.dsh-pins-remove{opacity:1}}
151
+ `
152
+
153
+ export const inject = ['slots', 'sessions', 'locale']
154
+
155
+ /** Register the lifecycle bridge in the additive frame overlay slot. */
156
+ export function apply(ctx: ClientContextLike): void {
157
+ let storage: Storage | undefined
158
+ try {
159
+ storage = window.localStorage
160
+ } catch {
161
+ storage = undefined
162
+ }
163
+ const store = new PinStore(storage)
164
+
165
+ ctx.effect(() => {
166
+ const onStorage = (event: StorageEvent): void => {
167
+ if (event.key === PIN_STORAGE_KEY || event.key === null) store.reload()
168
+ }
169
+ window.addEventListener('storage', onStorage)
170
+ return () => { window.removeEventListener('storage', onStorage) }
171
+ }, 'pinned-sessions: cross-tab state')
172
+
173
+ ctx.effect(() => {
174
+ if (document.querySelector(`style[data-plugin-css="${STYLE_ID}"]`) !== null) return () => {}
175
+ const tag = document.createElement('style')
176
+ tag.dataset.plugin = '@anionex/dsh-pinned-sessions'
177
+ tag.dataset.pluginCss = STYLE_ID
178
+ tag.textContent = styles
179
+ document.head.appendChild(tag)
180
+ return () => { tag.remove() }
181
+ }, 'pinned-sessions: styles')
182
+
183
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'pinned-sessions: dictionaries')
184
+
185
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
186
+ name: 'shell.overlay',
187
+ id: 'pinned-sessions-bridge',
188
+ order: 40,
189
+ locale: NS,
190
+ inject: () => ({ store, sessions: ctx.sessions }),
191
+ }, PinnedSessionsBridge))
192
+ }
193
+
194
+ /** Keep native behavior intact while mounting the sidebar portal and unmanaged menu item. */
195
+ export function PinnedSessionsBridge({ store, sessions, useSessions, useWorkspaces, t }: BridgeProps): ReactNode {
196
+ const sessionSnapshot = useSessions(snapshot => snapshot)
197
+ const workspaceSnapshot = useWorkspaces(snapshot => snapshot)
198
+ const pins = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
199
+ const [sidebarHost, setSidebarHost] = useState<HTMLDivElement | null>(null)
200
+ const [sidebarWide, setSidebarWide] = useState(true)
201
+ const pending = useRef<PendingMenuRequest | null>(null)
202
+ const activeMenu = useRef<ActiveMenuBinding | null>(null)
203
+ const focusAfterUnpin = useRef<number | null>(null)
204
+
205
+ useLayoutEffect(() => {
206
+ let active = true
207
+ let queued = false
208
+ let settleTimer: number | undefined
209
+ let observedSidebar: Element | null = null
210
+ const sidebarObserver = new MutationObserver(() => { queueRefresh() })
211
+
212
+ const disposeActiveMenu = (): void => {
213
+ const binding = activeMenu.current
214
+ if (binding === null) return
215
+ activeMenu.current = null
216
+ binding.target.button.removeEventListener('click', binding.onClick)
217
+ binding.target.host.remove()
218
+ if (binding.target.menu.getAttribute(MENU_OWNER_ATTRIBUTE) === binding.target.sessionId) {
219
+ binding.target.menu.removeAttribute(MENU_OWNER_ATTRIBUTE)
220
+ }
221
+ }
222
+ const updateActiveMenu = (): void => {
223
+ const binding = activeMenu.current
224
+ if (binding === null) return
225
+ const pinned = store.isPinned(binding.target.sessionId)
226
+ updateSessionMenuItem(binding.target, pinned, t(pinned ? 'menu.unpin' : 'menu.pin'))
227
+ }
228
+ const observeSidebar = (): void => {
229
+ const slot = document.querySelector(SIDEBAR_SLOT_SELECTOR)
230
+ if (slot === observedSidebar) return
231
+ sidebarObserver.disconnect()
232
+ observedSidebar = slot
233
+ if (slot !== null) {
234
+ sidebarObserver.observe(slot, {
235
+ attributes: true,
236
+ attributeFilter: ['class', 'style'],
237
+ childList: true,
238
+ subtree: true,
239
+ })
240
+ }
241
+ }
242
+ const refresh = (): void => {
243
+ if (!active) return
244
+ const nextHost = ensurePinnedHost(document)
245
+ setSidebarHost(current => current === nextHost ? current : nextHost)
246
+ observeSidebar()
247
+ const rootWidth = nextHost?.parentElement?.getBoundingClientRect().width
248
+ if (rootWidth !== undefined) setSidebarWide(rootWidth >= 160)
249
+
250
+ const binding = activeMenu.current
251
+ if (binding !== null && (!binding.target.host.isConnected || !binding.target.menu.isConnected)) {
252
+ disposeActiveMenu()
253
+ }
254
+ const request = pending.current
255
+ if (request === null) return
256
+ if (Date.now() - request.createdAt > MENU_ASSOCIATION_MS) {
257
+ pending.current = null
258
+ return
259
+ }
260
+ const menu = findUnclaimedPortalMenu(document, request.existingMenus, request.triggerRect)
261
+ if (menu === null) return
262
+ const target = attachSessionMenuHost(menu, request.sessionId)
263
+ if (target === null) return
264
+ const onClick = (event: MouseEvent): void => {
265
+ event.preventDefault()
266
+ event.stopPropagation()
267
+ store.toggle(target.sessionId)
268
+ disposeActiveMenu()
269
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
270
+ }
271
+ target.button.addEventListener('click', onClick)
272
+ activeMenu.current = { target, onClick }
273
+ pending.current = null
274
+ updateActiveMenu()
275
+ }
276
+ function queueRefresh(): void {
277
+ if (!active) return
278
+ if (settleTimer === undefined) {
279
+ settleTimer = window.setTimeout(() => {
280
+ settleTimer = undefined
281
+ refresh()
282
+ }, 250)
283
+ }
284
+ if (queued) return
285
+ queued = true
286
+ queueMicrotask(() => {
287
+ queued = false
288
+ refresh()
289
+ })
290
+ }
291
+ const onClickCapture = (event: MouseEvent): void => {
292
+ pending.current = null
293
+ const target = findSessionActionTarget(event.target)
294
+ if (target === null) return
295
+ const sessionId = captureSessionId(target.row, sessions)
296
+ if (sessionId === null) return
297
+ const rect = target.button.getBoundingClientRect()
298
+ pending.current = {
299
+ sessionId,
300
+ createdAt: Date.now(),
301
+ existingMenus: new Set(listPortalMenus(document)),
302
+ triggerRect: {
303
+ top: rect.top,
304
+ right: rect.right,
305
+ bottom: rect.bottom,
306
+ left: rect.left,
307
+ width: rect.width,
308
+ height: rect.height,
309
+ },
310
+ }
311
+ queueRefresh()
312
+ }
313
+ const onKeyDownCapture = (): void => { pending.current = null }
314
+
315
+ refresh()
316
+ document.addEventListener('click', onClickCapture, true)
317
+ document.addEventListener('keydown', onKeyDownCapture, true)
318
+ const bodyObserver = new MutationObserver(() => { queueRefresh() })
319
+ bodyObserver.observe(document.body, { childList: true })
320
+ const unsubscribe = store.subscribe(updateActiveMenu)
321
+ return () => {
322
+ active = false
323
+ pending.current = null
324
+ if (settleTimer !== undefined) window.clearTimeout(settleTimer)
325
+ unsubscribe()
326
+ bodyObserver.disconnect()
327
+ sidebarObserver.disconnect()
328
+ document.removeEventListener('click', onClickCapture, true)
329
+ document.removeEventListener('keydown', onKeyDownCapture, true)
330
+ disposeActiveMenu()
331
+ removeBridgeArtifacts(document)
332
+ }
333
+ }, [sessions, store, t])
334
+
335
+ useLayoutEffect(() => {
336
+ if (sidebarHost === null) return
337
+ const root = sidebarHost.parentElement
338
+ if (root === null) return
339
+ const update = (): void => { setSidebarWide(root.getBoundingClientRect().width >= 160) }
340
+ update()
341
+ if (typeof ResizeObserver === 'undefined') return
342
+ const observer = new ResizeObserver(update)
343
+ observer.observe(root)
344
+ return () => { observer.disconnect() }
345
+ }, [sidebarHost])
346
+
347
+ useEffect(() => {
348
+ if (sessionSnapshot.phase !== 'ready' || workspaceSnapshot.phase !== 'ready') return
349
+ const valid = new Set(sessionSnapshot.ids)
350
+ const archived = new Set(workspaceSnapshot.archivedSessionIds)
351
+ store.prune(sessionId => valid.has(sessionId) && !archived.has(sessionId))
352
+ }, [sessionSnapshot, store, workspaceSnapshot])
353
+
354
+ const rows = useMemo(() => {
355
+ const workspaceBySession = new Map<string, string>()
356
+ for (const workspace of workspaceSnapshot.items) {
357
+ for (const sessionId of workspace.sessionIds) {
358
+ if (!workspaceBySession.has(sessionId)) workspaceBySession.set(sessionId, workspace.title)
359
+ }
360
+ }
361
+ return pins.flatMap(pin => {
362
+ const session = sessionSnapshot.byId[pin.id]
363
+ if (session === undefined || session.blank === true) return []
364
+ const workspaceTitle = workspaceBySession.get(pin.id)
365
+ const workspace = workspaceTitle ?? t('workspace.ungrouped')
366
+ return [{ session, title: sessionDisplayTitle(session, workspaceTitle), workspace }]
367
+ })
368
+ }, [pins, sessionSnapshot.byId, t, workspaceSnapshot.items])
369
+
370
+ useLayoutEffect(() => {
371
+ const removedIndex = focusAfterUnpin.current
372
+ if (removedIndex === null) return
373
+ focusAfterUnpin.current = null
374
+ focusAfterPinnedRemoval(document, sidebarHost, removedIndex)
375
+ }, [rows, sidebarHost])
376
+
377
+ return sidebarHost !== null && sidebarHost.isConnected && sidebarWide && rows.length > 0
378
+ ? createPortal(
379
+ <PinnedSessionSection
380
+ currentId={sessionSnapshot.current}
381
+ rows={rows}
382
+ open={sessionId => { sessions.open(sessionId) }}
383
+ unpin={(sessionId, index) => {
384
+ focusAfterUnpin.current = index
385
+ store.toggle(sessionId)
386
+ }}
387
+ t={t}
388
+ />,
389
+ sidebarHost,
390
+ )
391
+ : null
392
+ }
393
+
394
+ interface PinnedSessionSectionProps {
395
+ readonly currentId?: string | undefined
396
+ readonly rows: readonly { readonly session: SessionSummaryLike; readonly title: string; readonly workspace: string }[]
397
+ readonly open: (sessionId: string) => void
398
+ readonly unpin: (sessionId: string, index: number) => void
399
+ readonly t: Translate
400
+ }
401
+
402
+ function PinnedSessionSection({ currentId, rows, open, unpin, t }: PinnedSessionSectionProps): ReactNode {
403
+ return <section className="dsh-pins-section" aria-label={t('section.label')}>
404
+ <div className="dsh-pins-header">
405
+ <PinIcon size={13} />
406
+ <span>{t('section.label')}</span>
407
+ </div>
408
+ <ul className="dsh-pins-list">
409
+ {rows.map(({ session, title, workspace }, index) => <li
410
+ className="dsh-pins-row"
411
+ data-current={session.id === currentId ? 'true' : 'false'}
412
+ key={session.id}
413
+ >
414
+ <button
415
+ type="button"
416
+ className="dsh-pins-open"
417
+ aria-current={session.id === currentId ? 'page' : undefined}
418
+ aria-label={t('session.open', { name: title })}
419
+ onClick={() => { open(session.id) }}
420
+ >
421
+ <PinIcon size={13} />
422
+ <span className="dsh-pins-title">{title}</span>
423
+ <span className="dsh-pins-workspace">{workspace}</span>
424
+ </button>
425
+ <button
426
+ type="button"
427
+ className="dsh-pins-remove"
428
+ aria-label={t('session.unpin', { name: title })}
429
+ title={t('session.unpin', { name: title })}
430
+ onClick={() => { unpin(session.id, index) }}
431
+ >
432
+ <CloseIcon />
433
+ </button>
434
+ </li>)}
435
+ </ul>
436
+ </section>
437
+ }
438
+
439
+ function PinIcon({ size = 16 }: PinIconProps): ReactNode {
440
+ return <Pin size={size} strokeWidth={1.8} aria-hidden="true" />
441
+ }
442
+
443
+ function CloseIcon(): ReactNode {
444
+ return <X size={14} strokeWidth={2} aria-hidden="true" />
445
+ }