@anionex/dsh-pinned-sessions 0.1.0 → 0.1.2

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.
@@ -7,7 +7,15 @@ import {
7
7
  useSyncExternalStore,
8
8
  type ReactNode,
9
9
  } from 'react'
10
- import { Pin, X } from 'lucide-react'
10
+ import {
11
+ Button,
12
+ Menu,
13
+ MenuItemButton,
14
+ Modal,
15
+ Toast,
16
+ type MenuEntry,
17
+ } from '@deepseek-ai/dsh-client-ui-primitives'
18
+ import { Pin, PinOff, Archive, GitBranch, Pencil, Ellipsis, Trash2 } from 'lucide-react'
11
19
  import { createPortal } from 'react-dom'
12
20
  import {
13
21
  attachSessionMenuHost,
@@ -77,19 +85,72 @@ interface LocaleLike {
77
85
  register(namespace: string, dictionaries: Readonly<Record<string, Readonly<Record<string, string>>>>): () => void
78
86
  }
79
87
 
88
+ interface ResultLike {
89
+ readonly ok: boolean
90
+ readonly error?: { readonly message: string } | undefined
91
+ }
92
+
93
+ interface ClientSessionsLike extends SessionsForCapture {
94
+ binding(sessionId: string): { readonly session: { rename(title: string): Promise<ResultLike> } } | undefined
95
+ fork(input: { readonly sessionId: string; readonly increaseTitle: boolean }): Promise<string>
96
+ }
97
+
98
+ interface WorkspacesServiceLike {
99
+ archiveSession(sessionId: string): Promise<void>
100
+ }
101
+
102
+ interface DeleteSessionServiceLike {
103
+ deleteSession(sessionId: string): Promise<ResultLike>
104
+ }
105
+
106
+ interface BooleanStoreLike {
107
+ readonly getSnapshot: () => boolean
108
+ readonly subscribe: (listener: () => void) => () => void
109
+ }
110
+
111
+ class BooleanStore implements BooleanStoreLike {
112
+ private value = false
113
+ private readonly listeners = new Set<() => void>()
114
+
115
+ readonly getSnapshot = (): boolean => this.value
116
+ readonly subscribe = (listener: () => void): (() => void) => {
117
+ this.listeners.add(listener)
118
+ return () => { this.listeners.delete(listener) }
119
+ }
120
+
121
+ set(value: boolean): void {
122
+ if (value === this.value) return
123
+ this.value = value
124
+ for (const listener of this.listeners) listener()
125
+ }
126
+ }
127
+
80
128
  interface ClientContextLike {
81
129
  readonly slots: SlotsLike
82
- readonly sessions: SessionsForCapture
130
+ readonly sessions: ClientSessionsLike
83
131
  readonly locale: LocaleLike
132
+ get(name: string): unknown
133
+ inject(dependencies: readonly string[], setup: (ctx: ClientContextLike) => void): unknown
84
134
  effect(setup: () => (() => void), label?: string): unknown
85
135
  }
86
136
 
137
+ export interface PinnedSessionActions {
138
+ readonly openSession?: (sessionId: string) => void
139
+ readonly renameSession: (sessionId: string, title: string) => Promise<void>
140
+ readonly forkSession: (sessionId: string) => Promise<void>
141
+ readonly archiveSession: (sessionId: string) => Promise<void>
142
+ readonly deleteAvailability: BooleanStoreLike
143
+ readonly deleteSession: (sessionId: string) => Promise<void>
144
+ }
145
+
87
146
  interface BridgeProps {
88
147
  readonly store: PinStore
89
148
  readonly sessions: SessionsForCapture
149
+ readonly actions: PinnedSessionActions
90
150
  readonly useSessions: SelectorHook<SessionsSnapshotLike>
91
151
  readonly useWorkspaces: SelectorHook<WorkspacesSnapshotLike>
92
152
  readonly t: Translate
153
+ readonly modernMenus?: BooleanStoreLike
93
154
  }
94
155
 
95
156
  interface PinIconProps {
@@ -108,25 +169,60 @@ interface ActiveMenuBinding {
108
169
  readonly onClick: (event: MouseEvent) => void
109
170
  }
110
171
 
172
+ interface PendingFocusRemoval {
173
+ readonly sessionId: string
174
+ readonly index: number
175
+ }
176
+
111
177
  const NS = 'pinnedSessions'
112
178
  const STYLE_ID = '@anionex/dsh-pinned-sessions'
113
179
  const MENU_ASSOCIATION_MS = 2_000
114
180
 
115
181
  const zh = {
116
- 'menu.pin': '置顶会话',
117
- 'menu.unpin': '取消置顶',
182
+ 'menu.rename': '重命名',
183
+ 'menu.fork': '分叉会话',
184
+ 'menu.pin': '置顶到顶部分组',
185
+ 'menu.unpin': '从顶部分组取消置顶',
186
+ 'menu.archive': '归档会话',
187
+ 'menu.delete': '删除会话',
118
188
  'section.label': '置顶会话',
119
189
  'session.open': '打开置顶会话“{name}”',
190
+ 'session.actions': '置顶会话“{name}”的操作',
191
+ 'session.archiveFailed': '归档会话失败:{detail}',
192
+ 'session.forkFailed': '分叉会话失败:{detail}',
120
193
  'session.unpin': '取消置顶“{name}”',
194
+ 'dialog.close': '关闭',
195
+ 'dialog.cancel': '取消',
196
+ 'rename.title': '重命名会话',
197
+ 'rename.field': '会话名称',
198
+ 'rename.confirm': '重命名',
199
+ 'delete.title': '删除会话',
200
+ 'delete.description': '将永久删除会话“{name}”及其子代理(含正在运行的)和全部记录(对话内容、统计、缓存),此操作不可恢复。',
201
+ 'delete.pending': '正在删除会话…',
121
202
  'workspace.ungrouped': '未分组',
122
203
  }
123
204
 
124
205
  const en = {
125
- 'menu.pin': 'Pin session',
126
- 'menu.unpin': 'Unpin session',
206
+ 'menu.rename': 'Rename',
207
+ 'menu.fork': 'Fork session',
208
+ 'menu.pin': 'Pin to top section',
209
+ 'menu.unpin': 'Unpin from top section',
210
+ 'menu.archive': 'Archive session',
211
+ 'menu.delete': 'Delete session',
127
212
  'section.label': 'Pinned sessions',
128
213
  'session.open': 'Open pinned session “{name}”',
214
+ 'session.actions': 'Actions for pinned session “{name}”',
215
+ 'session.archiveFailed': 'Could not archive session: {detail}',
216
+ 'session.forkFailed': 'Could not fork session: {detail}',
129
217
  'session.unpin': 'Unpin “{name}”',
218
+ 'dialog.close': 'Close',
219
+ 'dialog.cancel': 'Cancel',
220
+ 'rename.title': 'Rename session',
221
+ 'rename.field': 'Session name',
222
+ 'rename.confirm': 'Rename',
223
+ 'delete.title': 'Delete session',
224
+ 'delete.description': 'This permanently deletes session “{name}”, its child agents (including any that are still running), and all of its records (conversation, stats, cache). This cannot be undone.',
225
+ 'delete.pending': 'Deleting session…',
130
226
  'workspace.ungrouped': 'Ungrouped',
131
227
  }
132
228
 
@@ -137,20 +233,59 @@ const styles = `
137
233
  .dsh-pins-header svg{flex:none;color:var(--dsw-alias-state-business-primary)}
138
234
  .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
235
  .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)}
236
+ .dsh-pins-row:hover,.dsh-pins-row[data-current="true"],.dsh-pins-row[data-menu-open="true"]{background:var(--dsw-alias-interactive-bg-hover)}
141
237
  .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}
238
+ .dsh-pins-open:focus-visible,.dsh-pins-actions:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:-2px}
143
239
  .dsh-pins-open>svg{flex:none;color:var(--dsw-alias-state-business-primary)}
144
240
  .dsh-pins-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px;line-height:20px;flex:1}
145
241
  .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}}
242
+ .dsh-pins-actions{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;pointer-events:none;flex:none}
243
+ .dsh-pins-row:hover .dsh-pins-actions,.dsh-pins-row:focus-within .dsh-pins-actions,.dsh-pins-row[data-menu-open="true"] .dsh-pins-actions{opacity:1;pointer-events:auto}
244
+ .dsh-pins-actions:hover,.dsh-pins-row[data-menu-open="true"] .dsh-pins-actions{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
245
+ .dsh-pins-action-menu{display:inline-flex;flex:none}
246
+ .dsh-pins-rename-input{box-sizing:border-box;width:100%;height:36px;padding:0 11px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;outline:0;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font:inherit}
247
+ .dsh-pins-rename-input:focus{border-color:var(--dsw-alias-state-business-primary)}
248
+ .dsh-pins-dialog-error{margin-top:8px;color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px}
249
+ .dsh-pins-dialog-status{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
250
+ .dsh-pins-delete-action{color:var(--dsw-alias-state-error-primary)!important;border-color:var(--dsw-alias-state-error-primary)!important}
251
+ @media (max-width:767px){.dsh-pins-workspace{display:none}}
252
+ @media (hover:none),(max-width:767px){.dsh-pins-actions{opacity:1;pointer-events:auto}}
151
253
  `
152
254
 
153
- export const inject = ['slots', 'sessions', 'locale']
255
+ export const inject = ['slots', 'sessions', 'workspaces', 'locale']
256
+
257
+ function deleteSessionService(ctx: ClientContextLike): DeleteSessionServiceLike | null {
258
+ try {
259
+ const service = ctx.get('remote.workspaceRegistry') as Partial<DeleteSessionServiceLike> | undefined
260
+ return typeof service?.deleteSession === 'function' ? service as DeleteSessionServiceLike : null
261
+ } catch {
262
+ return null
263
+ }
264
+ }
265
+
266
+ function makePinnedSessionActions(ctx: ClientContextLike, deleteAvailability: BooleanStoreLike): PinnedSessionActions {
267
+ const workspaces = ctx.get('workspaces') as WorkspacesServiceLike
268
+ return {
269
+ renameSession: async (sessionId, title) => {
270
+ const session = ctx.sessions.binding(sessionId)?.session
271
+ if (session === undefined) throw new Error(`unknown session "${sessionId}"`)
272
+ const result = await session.rename(title)
273
+ if (!result.ok) throw new Error(result.error?.message ?? 'Session rename failed')
274
+ },
275
+ forkSession: async sessionId => {
276
+ const childId = await ctx.sessions.fork({ sessionId, increaseTitle: true })
277
+ ctx.sessions.open(childId)
278
+ },
279
+ archiveSession: async sessionId => { await workspaces.archiveSession(sessionId) },
280
+ deleteAvailability,
281
+ deleteSession: async sessionId => {
282
+ const service = deleteSessionService(ctx)
283
+ if (service === null) throw new Error('Session deletion is unavailable')
284
+ const result = await service.deleteSession(sessionId)
285
+ if (!result.ok) throw new Error(result.error?.message ?? 'Session deletion failed')
286
+ },
287
+ }
288
+ }
154
289
 
155
290
  /** Register the lifecycle bridge in the additive frame overlay slot. */
156
291
  export function apply(ctx: ClientContextLike): void {
@@ -161,6 +296,35 @@ export function apply(ctx: ClientContextLike): void {
161
296
  storage = undefined
162
297
  }
163
298
  const store = new PinStore(storage)
299
+ const deleteAvailability = new BooleanStore()
300
+ const actions = makePinnedSessionActions(ctx, deleteAvailability)
301
+ const modernMenus = new BooleanStore()
302
+ let openSession = (sessionId: string): void => { ctx.sessions.open(sessionId) }
303
+ const navigationActions = {
304
+ ...actions,
305
+ openSession: (id: string) => { openSession(id) },
306
+ forkSession: async (id: string) => { openSession(await ctx.sessions.fork({ sessionId: id, increaseTitle: true })) },
307
+ }
308
+ ctx.inject(['uiWorkspace'], navigation => {
309
+ const service = navigation.get('uiWorkspace') as { openSession(id: string): void }
310
+ openSession = id => { service.openSession(id) }
311
+ navigation.effect(() => () => { openSession = id => { ctx.sessions.open(id) } })
312
+ })
313
+
314
+ ctx.slots.inject('sidebar.workspaces.session.menu.item', () => {
315
+ modernMenus.set(true)
316
+ const dispose = ctx.slots.register({
317
+ name: 'sidebar.workspaces.session.menu.item', id: 'pinned-sessions', order: 90, locale: NS,
318
+ inject: () => ({ store }),
319
+ }, PinTopSectionMenuItem)
320
+ return () => { dispose(); modernMenus.set(false) }
321
+ })
322
+
323
+ ctx.inject(['remote.workspaceRegistry'], serviceCtx => {
324
+ const available = deleteSessionService(serviceCtx) !== null
325
+ deleteAvailability.set(available)
326
+ serviceCtx.effect(() => () => { deleteAvailability.set(false) }, 'pinned-sessions: delete capability')
327
+ })
164
328
 
165
329
  ctx.effect(() => {
166
330
  const onStorage = (event: StorageEvent): void => {
@@ -187,12 +351,27 @@ export function apply(ctx: ClientContextLike): void {
187
351
  id: 'pinned-sessions-bridge',
188
352
  order: 40,
189
353
  locale: NS,
190
- inject: () => ({ store, sessions: ctx.sessions }),
354
+ inject: () => ({ store, sessions: ctx.sessions, actions: navigationActions, modernMenus }),
191
355
  }, PinnedSessionsBridge))
192
356
  }
193
357
 
358
+ /** The official 0.2 menu slot supplies the exact Session identity. */
359
+ function PinTopSectionMenuItem({ sessionId, store, useMenuOpenState, t }: {
360
+ sessionId: string
361
+ store: PinStore
362
+ useMenuOpenState: () => [boolean, (open: boolean) => void]
363
+ t: Translate
364
+ }): ReactNode {
365
+ useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
366
+ const [, setMenuOpen] = useMenuOpenState()
367
+ const pinned = store.isPinned(sessionId)
368
+ return <MenuItemButton icon={<PinIcon />} onSelect={() => { store.toggle(sessionId); setMenuOpen(false) }}>
369
+ {t(pinned ? 'menu.unpin' : 'menu.pin')}
370
+ </MenuItemButton>
371
+ }
372
+
194
373
  /** Keep native behavior intact while mounting the sidebar portal and unmanaged menu item. */
195
- export function PinnedSessionsBridge({ store, sessions, useSessions, useWorkspaces, t }: BridgeProps): ReactNode {
374
+ export function PinnedSessionsBridge({ store, sessions, actions, useSessions, useWorkspaces, t, modernMenus }: BridgeProps): ReactNode {
196
375
  const sessionSnapshot = useSessions(snapshot => snapshot)
197
376
  const workspaceSnapshot = useWorkspaces(snapshot => snapshot)
198
377
  const pins = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
@@ -200,7 +379,7 @@ export function PinnedSessionsBridge({ store, sessions, useSessions, useWorkspac
200
379
  const [sidebarWide, setSidebarWide] = useState(true)
201
380
  const pending = useRef<PendingMenuRequest | null>(null)
202
381
  const activeMenu = useRef<ActiveMenuBinding | null>(null)
203
- const focusAfterUnpin = useRef<number | null>(null)
382
+ const focusAfterRemoval = useRef<PendingFocusRemoval | null>(null)
204
383
 
205
384
  useLayoutEffect(() => {
206
385
  let active = true
@@ -290,6 +469,7 @@ export function PinnedSessionsBridge({ store, sessions, useSessions, useWorkspac
290
469
  }
291
470
  const onClickCapture = (event: MouseEvent): void => {
292
471
  pending.current = null
472
+ if (modernMenus?.getSnapshot()) return
293
473
  const target = findSessionActionTarget(event.target)
294
474
  if (target === null) return
295
475
  const sessionId = captureSessionId(target.row, sessions)
@@ -330,7 +510,7 @@ export function PinnedSessionsBridge({ store, sessions, useSessions, useWorkspac
330
510
  disposeActiveMenu()
331
511
  removeBridgeArtifacts(document)
332
512
  }
333
- }, [sessions, store, t])
513
+ }, [sessions, store, t, modernMenus])
334
514
 
335
515
  useLayoutEffect(() => {
336
516
  if (sidebarHost === null) return
@@ -368,20 +548,27 @@ export function PinnedSessionsBridge({ store, sessions, useSessions, useWorkspac
368
548
  }, [pins, sessionSnapshot.byId, t, workspaceSnapshot.items])
369
549
 
370
550
  useLayoutEffect(() => {
371
- const removedIndex = focusAfterUnpin.current
372
- if (removedIndex === null) return
373
- focusAfterUnpin.current = null
374
- focusAfterPinnedRemoval(document, sidebarHost, removedIndex)
551
+ const pendingRemoval = focusAfterRemoval.current
552
+ if (pendingRemoval === null || rows.some(row => row.session.id === pendingRemoval.sessionId)) return
553
+ focusAfterRemoval.current = null
554
+ focusAfterPinnedRemoval(document, sidebarHost, pendingRemoval.index)
375
555
  }, [rows, sidebarHost])
376
556
 
377
557
  return sidebarHost !== null && sidebarHost.isConnected && sidebarWide && rows.length > 0
378
558
  ? createPortal(
379
559
  <PinnedSessionSection
560
+ actions={actions}
561
+ cancelRemoval={sessionId => {
562
+ if (focusAfterRemoval.current?.sessionId === sessionId) focusAfterRemoval.current = null
563
+ }}
380
564
  currentId={sessionSnapshot.current}
381
565
  rows={rows}
382
- open={sessionId => { sessions.open(sessionId) }}
566
+ open={sessionId => { if (actions.openSession) actions.openSession(sessionId); else sessions.open(sessionId) }}
567
+ prepareRemoval={(sessionId, index) => {
568
+ focusAfterRemoval.current = { sessionId, index }
569
+ }}
383
570
  unpin={(sessionId, index) => {
384
- focusAfterUnpin.current = index
571
+ focusAfterRemoval.current = { sessionId, index }
385
572
  store.toggle(sessionId)
386
573
  }}
387
574
  t={t}
@@ -392,54 +579,330 @@ export function PinnedSessionsBridge({ store, sessions, useSessions, useWorkspac
392
579
  }
393
580
 
394
581
  interface PinnedSessionSectionProps {
582
+ readonly actions: PinnedSessionActions
583
+ readonly cancelRemoval: (sessionId: string) => void
395
584
  readonly currentId?: string | undefined
396
585
  readonly rows: readonly { readonly session: SessionSummaryLike; readonly title: string; readonly workspace: string }[]
397
586
  readonly open: (sessionId: string) => void
587
+ readonly prepareRemoval: (sessionId: string, index: number) => void
398
588
  readonly unpin: (sessionId: string, index: number) => void
399
589
  readonly t: Translate
400
590
  }
401
591
 
402
- function PinnedSessionSection({ currentId, rows, open, unpin, t }: PinnedSessionSectionProps): ReactNode {
592
+ function PinnedSessionSection({ actions, cancelRemoval, currentId, rows, open, prepareRemoval, unpin, t }: PinnedSessionSectionProps): ReactNode {
403
593
  return <section className="dsh-pins-section" aria-label={t('section.label')}>
404
594
  <div className="dsh-pins-header">
405
595
  <PinIcon size={13} />
406
596
  <span>{t('section.label')}</span>
407
597
  </div>
408
598
  <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'}
599
+ {rows.map(({ session, title, workspace }, index) => <PinnedSessionRow
600
+ actions={actions}
601
+ cancelRemoval={cancelRemoval}
602
+ current={session.id === currentId}
603
+ index={index}
412
604
  key={session.id}
605
+ open={open}
606
+ prepareRemoval={prepareRemoval}
607
+ session={session}
608
+ t={t}
609
+ title={title}
610
+ unpin={unpin}
611
+ workspace={workspace}
612
+ />)}
613
+ </ul>
614
+ </section>
615
+ }
616
+
617
+ interface PinnedSessionRowProps {
618
+ readonly actions: PinnedSessionActions
619
+ readonly cancelRemoval: (sessionId: string) => void
620
+ readonly current: boolean
621
+ readonly index: number
622
+ readonly open: (sessionId: string) => void
623
+ readonly prepareRemoval: (sessionId: string, index: number) => void
624
+ readonly session: SessionSummaryLike
625
+ readonly t: Translate
626
+ readonly title: string
627
+ readonly unpin: (sessionId: string, index: number) => void
628
+ readonly workspace: string
629
+ }
630
+
631
+ function PinnedSessionRow({ actions, cancelRemoval, current, index, open, prepareRemoval, session, t, title, unpin, workspace }: PinnedSessionRowProps): ReactNode {
632
+ const [menuOpen, setMenuOpen] = useState(false)
633
+ const [renameOpen, setRenameOpen] = useState(false)
634
+ const [renameDraft, setRenameDraft] = useState('')
635
+ const [renaming, setRenaming] = useState(false)
636
+ const [renameError, setRenameError] = useState<string | null>(null)
637
+ const [deleteOpen, setDeleteOpen] = useState(false)
638
+ const [deleting, setDeleting] = useState(false)
639
+ const [deleteError, setDeleteError] = useState<string | null>(null)
640
+ const [actionToast, setActionToast] = useState<{ readonly text: string; readonly seq: number } | null>(null)
641
+ const toastSeq = useRef(0)
642
+ const composing = useRef(false)
643
+ const trigger = useRef<HTMLButtonElement | null>(null)
644
+ const menusBeforeOpen = useRef<ReadonlySet<HTMLElement>>(new Set())
645
+ const focusMenuEdge = useRef<'first' | 'last' | null>(null)
646
+ const actionLabel = t('session.actions', { name: title })
647
+ const renameTrimmed = renameDraft.trim()
648
+ const renameBlocked = renaming || renameTrimmed === ''
649
+ const canDelete = useSyncExternalStore(
650
+ actions.deleteAvailability.subscribe,
651
+ actions.deleteAvailability.getSnapshot,
652
+ actions.deleteAvailability.getSnapshot,
653
+ )
654
+ const showDesktopActionError = (key: string, reason: unknown): void => {
655
+ if (!canDelete) return
656
+ toastSeq.current += 1
657
+ setActionToast({
658
+ text: t(key, { detail: reason instanceof Error ? reason.message : String(reason) }),
659
+ seq: toastSeq.current,
660
+ })
661
+ }
662
+
663
+ const restoreTriggerFocus = (): void => {
664
+ queueMicrotask(() => { trigger.current?.focus({ preventScroll: true }) })
665
+ }
666
+ const openActionMenu = (focusEdge: 'first' | 'last' | null): void => {
667
+ menusBeforeOpen.current = new Set(document.querySelectorAll<HTMLElement>('[role="menu"]'))
668
+ focusMenuEdge.current = focusEdge
669
+ setMenuOpen(true)
670
+ }
671
+
672
+ useLayoutEffect(() => {
673
+ if (!menuOpen) return
674
+ let menu: HTMLElement | undefined
675
+ let observer: MutationObserver | undefined
676
+ let disposed = false
677
+ const items = (): HTMLButtonElement[] => menu === undefined
678
+ ? []
679
+ : [...menu.querySelectorAll<HTMLButtonElement>('[role="menuitem"]:not(:disabled)')]
680
+ const onMenuKeyDown = (event: KeyboardEvent): void => {
681
+ if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return
682
+ const available = items()
683
+ if (available.length === 0) return
684
+ const currentIndex = available.indexOf(document.activeElement as HTMLButtonElement)
685
+ let nextIndex: number
686
+ if (event.key === 'Home') nextIndex = 0
687
+ else if (event.key === 'End') nextIndex = available.length - 1
688
+ else if (event.key === 'ArrowDown') nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % available.length
689
+ else nextIndex = currentIndex <= 0 ? available.length - 1 : currentIndex - 1
690
+ event.preventDefault()
691
+ available[nextIndex]?.focus({ preventScroll: true })
692
+ }
693
+ const onDocumentKeyDown = (event: KeyboardEvent): void => {
694
+ if (event.key === 'Escape') restoreTriggerFocus()
695
+ }
696
+ const install = (): void => {
697
+ if (disposed) return
698
+ menu = [...document.querySelectorAll<HTMLElement>('[role="menu"]')]
699
+ .find(candidate => !menusBeforeOpen.current.has(candidate))
700
+ if (menu === undefined) return
701
+ observer?.disconnect()
702
+ menu.addEventListener('keydown', onMenuKeyDown)
703
+ const edge = focusMenuEdge.current
704
+ focusMenuEdge.current = null
705
+ if (edge === null) return
706
+ const available = items()
707
+ const target = edge === 'first' ? available[0] : available.at(-1)
708
+ queueMicrotask(() => {
709
+ if (!disposed && target?.isConnected === true) target.focus({ preventScroll: true })
710
+ })
711
+ }
712
+ document.addEventListener('keydown', onDocumentKeyDown, true)
713
+ install()
714
+ if (menu === undefined) {
715
+ observer = new MutationObserver(install)
716
+ observer.observe(document.body, { childList: true, subtree: true })
717
+ install()
718
+ }
719
+ return () => {
720
+ disposed = true
721
+ observer?.disconnect()
722
+ menu?.removeEventListener('keydown', onMenuKeyDown)
723
+ document.removeEventListener('keydown', onDocumentKeyDown, true)
724
+ }
725
+ }, [menuOpen])
726
+
727
+ const menuItems: MenuEntry[] = [
728
+ { id: 'rename', label: t('menu.rename'), icon: <Pencil size={16} /> },
729
+ { id: 'fork', label: t('menu.fork'), icon: <GitBranch size={16} /> },
730
+ { id: 'unpin', label: t('menu.unpin'), icon: <PinOff size={16} strokeWidth={1.8} aria-hidden="true" /> },
731
+ { id: 'archive', label: t('menu.archive'), icon: <Archive size={16} /> },
732
+ ...(canDelete ? [{ id: 'delete', label: t('menu.delete'), icon: <Trash2 size={16} />, danger: true }] : []),
733
+ ]
734
+
735
+ const closeRename = (): void => {
736
+ if (renaming) return
737
+ setRenameOpen(false)
738
+ setRenameError(null)
739
+ restoreTriggerFocus()
740
+ }
741
+ const confirmRename = (): void => {
742
+ if (renameBlocked) return
743
+ setRenaming(true)
744
+ setRenameError(null)
745
+ void actions.renameSession(session.id, renameTrimmed).then(() => {
746
+ setRenaming(false)
747
+ setRenameOpen(false)
748
+ restoreTriggerFocus()
749
+ }).catch(reason => {
750
+ setRenaming(false)
751
+ setRenameError(reason instanceof Error ? reason.message : String(reason))
752
+ })
753
+ }
754
+ const closeDelete = (): void => {
755
+ if (deleting) return
756
+ setDeleteOpen(false)
757
+ setDeleteError(null)
758
+ restoreTriggerFocus()
759
+ }
760
+ const confirmDelete = (): void => {
761
+ if (deleting) return
762
+ prepareRemoval(session.id, index)
763
+ setDeleting(true)
764
+ setDeleteError(null)
765
+ void actions.deleteSession(session.id).then(() => {
766
+ setDeleting(false)
767
+ setDeleteOpen(false)
768
+ restoreTriggerFocus()
769
+ }).catch(reason => {
770
+ cancelRemoval(session.id)
771
+ setDeleting(false)
772
+ setDeleteError(reason instanceof Error ? reason.message : String(reason))
773
+ })
774
+ }
775
+ const onSelect = (id: string): void => {
776
+ setMenuOpen(false)
777
+ if (id === 'rename') {
778
+ setRenameDraft(session.title ?? title)
779
+ setRenameError(null)
780
+ setRenameOpen(true)
781
+ }
782
+ if (id === 'fork') {
783
+ restoreTriggerFocus()
784
+ void actions.forkSession(session.id).catch(reason => {
785
+ showDesktopActionError('session.forkFailed', reason)
786
+ })
787
+ }
788
+ if (id === 'unpin') unpin(session.id, index)
789
+ if (id === 'archive') {
790
+ prepareRemoval(session.id, index)
791
+ restoreTriggerFocus()
792
+ void actions.archiveSession(session.id).catch(reason => {
793
+ cancelRemoval(session.id)
794
+ restoreTriggerFocus()
795
+ showDesktopActionError('session.archiveFailed', reason)
796
+ if (!canDelete) console.warn('session archive rejected:', reason)
797
+ })
798
+ }
799
+ if (id === 'delete' && canDelete) {
800
+ setDeleteError(null)
801
+ setDeleteOpen(true)
802
+ }
803
+ }
804
+
805
+ return <>
806
+ <li
807
+ className="dsh-pins-row"
808
+ data-current={current ? 'true' : 'false'}
809
+ data-menu-open={menuOpen ? 'true' : undefined}
810
+ >
811
+ <button
812
+ type="button"
813
+ className="dsh-pins-open"
814
+ aria-current={current ? 'page' : undefined}
815
+ aria-label={t('session.open', { name: title })}
816
+ onClick={() => { open(session.id) }}
413
817
  >
414
- <button
818
+ <PinIcon size={13} />
819
+ <span className="dsh-pins-title">{title}</span>
820
+ <span className="dsh-pins-workspace">{workspace}</span>
821
+ </button>
822
+ <Menu
823
+ className="dsh-pins-action-menu"
824
+ open={menuOpen}
825
+ onClose={() => { setMenuOpen(false) }}
826
+ items={menuItems}
827
+ onSelect={onSelect}
828
+ portal
829
+ closeOnPointerLeave
830
+ anchor={<button
831
+ ref={trigger}
415
832
  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) }}
833
+ className="dsh-pins-actions"
834
+ aria-expanded={menuOpen}
835
+ aria-haspopup="menu"
836
+ aria-label={actionLabel}
837
+ title={actionLabel}
838
+ onClick={event => {
839
+ event.stopPropagation()
840
+ if (menuOpen) setMenuOpen(false)
841
+ else openActionMenu(null)
842
+ }}
843
+ onKeyDown={event => {
844
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
845
+ event.preventDefault()
846
+ openActionMenu(event.key === 'ArrowDown' ? 'first' : 'last')
847
+ }}
431
848
  >
432
- <CloseIcon />
433
- </button>
434
- </li>)}
435
- </ul>
436
- </section>
849
+ <Ellipsis size={16} />
850
+ </button>}
851
+ />
852
+ </li>
853
+ <Modal
854
+ open={renameOpen}
855
+ onClose={closeRename}
856
+ closeLabel={t('dialog.close')}
857
+ title={t('rename.title')}
858
+ footer={<>
859
+ <Button variant="outline" disabled={renaming} onClick={closeRename}>{t('dialog.cancel')}</Button>
860
+ <Button variant="primary" disabled={renameBlocked} onClick={confirmRename}>{t('rename.confirm')}</Button>
861
+ </>}
862
+ >
863
+ <input
864
+ className="dsh-pins-rename-input"
865
+ value={renameDraft}
866
+ aria-label={t('rename.field')}
867
+ autoFocus
868
+ disabled={renaming}
869
+ onFocus={event => { event.currentTarget.select() }}
870
+ onChange={event => {
871
+ setRenameDraft(event.currentTarget.value)
872
+ setRenameError(null)
873
+ }}
874
+ onCompositionStart={() => { composing.current = true }}
875
+ onCompositionEnd={() => { composing.current = false }}
876
+ onKeyDown={event => {
877
+ if (event.key !== 'Enter' || composing.current) return
878
+ event.preventDefault()
879
+ confirmRename()
880
+ }}
881
+ />
882
+ {renameError !== null && <div className="dsh-pins-dialog-error" role="alert">{renameError}</div>}
883
+ </Modal>
884
+ <Modal
885
+ open={deleteOpen}
886
+ onClose={closeDelete}
887
+ closeLabel={t('dialog.close')}
888
+ title={t('delete.title')}
889
+ description={t('delete.description', { name: title })}
890
+ footer={<>
891
+ <Button variant="outline" autoFocus disabled={deleting} onClick={closeDelete}>{t('dialog.cancel')}</Button>
892
+ <Button variant="outline" className="dsh-pins-delete-action" disabled={deleting} onClick={confirmDelete}>{t('delete.title')}</Button>
893
+ </>}
894
+ >
895
+ {deleting && <div className="dsh-pins-dialog-status" role="status">{t('delete.pending')}</div>}
896
+ {deleteError !== null && <div className="dsh-pins-dialog-error" role="alert">{deleteError}</div>}
897
+ </Modal>
898
+ {actionToast !== null && <Toast
899
+ key={actionToast.seq}
900
+ text={actionToast.text}
901
+ onDone={() => { setActionToast(null) }}
902
+ />}
903
+ </>
437
904
  }
438
905
 
439
906
  function PinIcon({ size = 16 }: PinIconProps): ReactNode {
440
907
  return <Pin size={size} strokeWidth={1.8} aria-hidden="true" />
441
908
  }
442
-
443
- function CloseIcon(): ReactNode {
444
- return <X size={14} strokeWidth={2} aria-hidden="true" />
445
- }