@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.
- package/CHANGELOG.md +41 -0
- 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 +534 -46
- package/lib/client.js.map +1 -1
- package/lib/types/client/index.d.ts +35 -2
- package/package.json +10 -5
- package/src/client/dsh-primitives.d.ts +51 -0
- package/src/client/index.tsx +516 -53
package/src/client/index.tsx
CHANGED
|
@@ -7,7 +7,15 @@ import {
|
|
|
7
7
|
useSyncExternalStore,
|
|
8
8
|
type ReactNode,
|
|
9
9
|
} from 'react'
|
|
10
|
-
import {
|
|
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:
|
|
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.
|
|
117
|
-
'menu.
|
|
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.
|
|
126
|
-
'menu.
|
|
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-
|
|
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-
|
|
147
|
-
.dsh-pins-row:hover .dsh-pins-
|
|
148
|
-
.dsh-pins-
|
|
149
|
-
.dsh-pins-menu
|
|
150
|
-
|
|
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
|
|
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
|
|
372
|
-
if (
|
|
373
|
-
|
|
374
|
-
focusAfterPinnedRemoval(document, sidebarHost,
|
|
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
|
-
|
|
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) => <
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
<
|
|
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-
|
|
417
|
-
aria-
|
|
418
|
-
aria-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
<
|
|
433
|
-
</button>
|
|
434
|
-
|
|
435
|
-
</
|
|
436
|
-
|
|
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
|
-
}
|