@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.
- package/README.md +79 -20
- package/README.zh.md +81 -22
- package/assets/hero.png +0 -0
- package/assets/pinned-session-menu.png +0 -0
- package/assets/social-preview.png +0 -0
- package/lib/client.js +433 -42
- package/lib/client.js.map +1 -1
- package/lib/types/client/index.d.ts +33 -2
- package/package.json +5 -2
- package/src/client/dsh-primitives.d.ts +50 -0
- package/src/client/index.tsx +475 -47
package/src/client/index.tsx
CHANGED
|
@@ -7,7 +7,19 @@ import {
|
|
|
7
7
|
useSyncExternalStore,
|
|
8
8
|
type ReactNode,
|
|
9
9
|
} from 'react'
|
|
10
|
-
import {
|
|
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:
|
|
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-
|
|
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-
|
|
147
|
-
.dsh-pins-row:hover .dsh-pins-
|
|
148
|
-
.dsh-pins-
|
|
149
|
-
.dsh-pins-menu
|
|
150
|
-
|
|
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
|
|
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
|
|
372
|
-
if (
|
|
373
|
-
|
|
374
|
-
focusAfterPinnedRemoval(document, sidebarHost,
|
|
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
|
-
|
|
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) => <
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
<
|
|
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-
|
|
417
|
-
aria-
|
|
418
|
-
aria-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
<
|
|
433
|
-
</button>
|
|
434
|
-
|
|
435
|
-
</
|
|
436
|
-
|
|
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
|
-
}
|