@anionex/dsh-pinned-sessions 0.1.0 → 0.1.1

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