@linxin666/dsh-client-ui-task-board 0.2.0 → 0.2.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/README.i18n.yaml +2 -2
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +349 -73
- package/lib/client.js.map +1 -1
- package/lib/index.js +161 -31
- package/lib/types/client/PluginSettingsCard.d.ts +22 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +2 -2
- package/lib/types/client/host-api.d.ts +3 -3
- package/lib/types/client/sidebar-entry-core.d.ts +60 -0
- package/lib/types/client/sidebar-entry.d.ts +8 -10
- package/lib/types/core/controller.d.ts +9 -2
- package/lib/types/core/schedule.d.ts +7 -0
- package/lib/types/host-ledger.d.ts +10 -0
- package/lib/types/host-routes.d.ts +3 -1
- package/lib/types/host-runner.d.ts +12 -1
- package/lib/types/host-service.d.ts +5 -1
- package/lib/types/protocol.d.ts +6 -0
- package/package.json +1 -2
- package/src/client/PluginSettingsCard.tsx +219 -18
- package/src/client/TaskBoardSettingsCard.tsx +10 -10
- package/src/client/board/TaskBoard.tsx +5 -5
- package/src/client/board/TaskCard.tsx +1 -0
- package/src/client/board/TaskDetail.tsx +1 -1
- package/src/client/board-mount.tsx +1 -0
- package/src/client/host-api.ts +12 -3
- package/src/client/settings-card.module.css +90 -1
- package/src/client/sidebar-entry-core.ts +208 -0
- package/src/client/sidebar-entry.ts +24 -144
- package/src/core/controller.ts +20 -3
- package/src/core/execution.ts +1 -2
- package/src/core/schedule.ts +57 -10
- package/src/host-ledger.ts +67 -6
- package/src/host-routes.ts +11 -3
- package/src/host-runner.ts +24 -6
- package/src/host-service.ts +24 -7
- package/src/protocol.ts +7 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-client-ui-task-board",
|
|
3
3
|
"description": "Host-authoritative task board for the DSH Web GUI with real session execution, Host cron scheduling, and optional cross-platform idle-sleep protection; mounted without DSH source changes.",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -55,7 +55,6 @@
|
|
|
55
55
|
"@types/react-dom": "^18.3.5",
|
|
56
56
|
"@types/node": "^22.20.0",
|
|
57
57
|
"jsdom": "^25.0.0",
|
|
58
|
-
"lightningcss": "1.32.0",
|
|
59
58
|
"react": "^18.3.1",
|
|
60
59
|
"react-dom": "^18.3.1",
|
|
61
60
|
"tsdown": "0.22.2",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* ui-plugin-config PluginCard in a self-contained slice.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { useState, type ReactNode } from 'react'
|
|
11
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
|
|
12
12
|
import type { CardShell } from './settings-form.ts'
|
|
13
13
|
import css from './settings-card.module.css'
|
|
14
14
|
|
|
@@ -255,6 +255,210 @@ export function ValueField(props: FieldProps & {
|
|
|
255
255
|
)
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
const NON_SKIN_BODY_MARKERS = new Set(['dshSkinCenter', 'dshSidebarCollapsed'])
|
|
259
|
+
// 检测使用使用皮肤,用了皮肤退回原生select样式,防止样式冲突。默认外观下使用优化后的select样式。
|
|
260
|
+
function isSkinActive(): boolean {
|
|
261
|
+
const datasetList = Object.keys(document.body.dataset)
|
|
262
|
+
const isActive = datasetList.some(key => key.startsWith('dsh') && !NON_SKIN_BODY_MARKERS.has(key))
|
|
263
|
+
return isActive
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
interface SelectOption {
|
|
267
|
+
value: string
|
|
268
|
+
label: string
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const SELECT_CLOSE_MS = 100
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The shared dual-mode select control. While an appearance skin is active it
|
|
275
|
+
* renders the legacy native `<select>` untouched, so element-level skin
|
|
276
|
+
* selectors keep working; under the default appearance it renders a
|
|
277
|
+
* self-drawn `role="listbox"` popup whose open/close is transition-animated.
|
|
278
|
+
* Staged cards reach it through BooleanField/ChoiceField; immediate-apply
|
|
279
|
+
* editors (the side-card prefs) bind it directly through onEdit.
|
|
280
|
+
* 双模式下拉框:皮肤激活时用原生 select,默认外观用自绘动画弹层。
|
|
281
|
+
*/
|
|
282
|
+
export function SelectField(props: {
|
|
283
|
+
id: string
|
|
284
|
+
options: ReadonlyArray<SelectOption>
|
|
285
|
+
value: string
|
|
286
|
+
disabled: boolean
|
|
287
|
+
invalid: boolean
|
|
288
|
+
onEdit: (text: string) => void
|
|
289
|
+
}) {
|
|
290
|
+
const { id, options, value } = props
|
|
291
|
+
const [open, setOpen] = useState(false)
|
|
292
|
+
const [closing, setClosing] = useState(false)
|
|
293
|
+
const [phase, setPhase] = useState<'initial' | 'open'>('initial')
|
|
294
|
+
const [activeIndex, setActiveIndex] = useState(0)
|
|
295
|
+
const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
296
|
+
const wrapRef = useRef<HTMLDivElement | null>(null)
|
|
297
|
+
const popupRef = useRef<HTMLDivElement | null>(null)
|
|
298
|
+
|
|
299
|
+
const currentIndex = () => {
|
|
300
|
+
const index = options.findIndex(option => option.value === value)
|
|
301
|
+
return index >= 0 ? index : 0
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const close = useCallback(() => {
|
|
305
|
+
if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
|
|
306
|
+
setClosing(true)
|
|
307
|
+
closeTimer.current = setTimeout(() => {
|
|
308
|
+
setClosing(false)
|
|
309
|
+
setOpen(false)
|
|
310
|
+
}, SELECT_CLOSE_MS)
|
|
311
|
+
}, [])
|
|
312
|
+
|
|
313
|
+
const openPopup = () => {
|
|
314
|
+
if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
|
|
315
|
+
setActiveIndex(currentIndex())
|
|
316
|
+
setPhase('initial')
|
|
317
|
+
setClosing(false)
|
|
318
|
+
setOpen(true)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const commit = (index: number) => {
|
|
322
|
+
const option = options[index]
|
|
323
|
+
if (option) props.onEdit(option.value)
|
|
324
|
+
close()
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const onTriggerClick = () => {
|
|
328
|
+
if (props.disabled) return
|
|
329
|
+
if (open && !closing) close()
|
|
330
|
+
else openPopup()
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
334
|
+
if (props.disabled) return
|
|
335
|
+
const count = options.length
|
|
336
|
+
switch (event.key) {
|
|
337
|
+
case 'ArrowDown':
|
|
338
|
+
case 'ArrowUp':
|
|
339
|
+
case 'Enter':
|
|
340
|
+
case ' ':
|
|
341
|
+
event.preventDefault()
|
|
342
|
+
if (!open) {
|
|
343
|
+
openPopup()
|
|
344
|
+
} else if (!closing) {
|
|
345
|
+
if (event.key === 'ArrowDown') setActiveIndex(index => (index + 1) % count)
|
|
346
|
+
else if (event.key === 'ArrowUp') setActiveIndex(index => (index - 1 + count) % count)
|
|
347
|
+
else commit(activeIndex)
|
|
348
|
+
}
|
|
349
|
+
break
|
|
350
|
+
case 'Escape':
|
|
351
|
+
if (open) {
|
|
352
|
+
event.preventDefault()
|
|
353
|
+
event.stopPropagation()
|
|
354
|
+
close()
|
|
355
|
+
}
|
|
356
|
+
break
|
|
357
|
+
case 'Tab':
|
|
358
|
+
if (open) close()
|
|
359
|
+
break
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
useEffect(() => () => {
|
|
364
|
+
if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
|
|
365
|
+
}, [])
|
|
366
|
+
useLayoutEffect(() => {
|
|
367
|
+
if (open && !closing && phase === 'initial') {
|
|
368
|
+
void popupRef.current?.offsetHeight
|
|
369
|
+
setPhase('open')
|
|
370
|
+
}
|
|
371
|
+
}, [open, closing, phase])
|
|
372
|
+
|
|
373
|
+
useEffect(() => {
|
|
374
|
+
if (!open) return
|
|
375
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
376
|
+
const target = event.target
|
|
377
|
+
if (target instanceof Node && !wrapRef.current?.contains(target)) close()
|
|
378
|
+
}
|
|
379
|
+
document.addEventListener('pointerdown', onPointerDown)
|
|
380
|
+
return () => document.removeEventListener('pointerdown', onPointerDown)
|
|
381
|
+
}, [open, close])
|
|
382
|
+
|
|
383
|
+
useEffect(() => {
|
|
384
|
+
if (props.disabled && open) close()
|
|
385
|
+
}, [props.disabled, open, close])
|
|
386
|
+
|
|
387
|
+
// 使用了皮肤则退回原生select样式防止样式冲突。
|
|
388
|
+
if (isSkinActive()) {
|
|
389
|
+
return (
|
|
390
|
+
<select
|
|
391
|
+
id={id}
|
|
392
|
+
className={css.select}
|
|
393
|
+
value={value}
|
|
394
|
+
disabled={props.disabled}
|
|
395
|
+
onChange={(event) => { props.onEdit(event.target.value) }}
|
|
396
|
+
>
|
|
397
|
+
{options.map(option => (
|
|
398
|
+
<option key={option.value} value={option.value}>{option.label}</option>
|
|
399
|
+
))}
|
|
400
|
+
</select>
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const label = options.find(option => option.value === value)?.label ?? ''
|
|
405
|
+
const popupClass = closing
|
|
406
|
+
? `${css.selectPopup} ${css.selectPopupClose}`
|
|
407
|
+
: phase === 'open'
|
|
408
|
+
? `${css.selectPopup} ${css.selectPopupOpen}`
|
|
409
|
+
: css.selectPopup
|
|
410
|
+
return (
|
|
411
|
+
<div className={css.selectWrap} ref={wrapRef}>
|
|
412
|
+
<button
|
|
413
|
+
type="button"
|
|
414
|
+
id={id}
|
|
415
|
+
className={`${css.select} ${css.selectButton}`}
|
|
416
|
+
disabled={props.disabled}
|
|
417
|
+
aria-haspopup="listbox"
|
|
418
|
+
aria-expanded={open}
|
|
419
|
+
aria-activedescendant={open ? `${id}-o${activeIndex}` : undefined}
|
|
420
|
+
aria-invalid={props.invalid || undefined}
|
|
421
|
+
onClick={onTriggerClick}
|
|
422
|
+
onKeyDown={onKeyDown}
|
|
423
|
+
>
|
|
424
|
+
<span className={css.selectLabel}>{label}</span>
|
|
425
|
+
<svg
|
|
426
|
+
width="14"
|
|
427
|
+
height="14"
|
|
428
|
+
viewBox="0 0 14 14"
|
|
429
|
+
fill="none"
|
|
430
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
431
|
+
className={open ? `${css.selectChevron} ${css.selectChevronOpen}` : css.selectChevron}
|
|
432
|
+
aria-hidden="true"
|
|
433
|
+
>
|
|
434
|
+
<path
|
|
435
|
+
d="M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z"
|
|
436
|
+
fill="currentColor"
|
|
437
|
+
/>
|
|
438
|
+
</svg>
|
|
439
|
+
</button>
|
|
440
|
+
{open
|
|
441
|
+
? (
|
|
442
|
+
<div className={popupClass} role="listbox" ref={popupRef}>
|
|
443
|
+
{options.map((option, index) => (
|
|
444
|
+
<div
|
|
445
|
+
key={option.value}
|
|
446
|
+
id={`${id}-o${index}`}
|
|
447
|
+
role="option"
|
|
448
|
+
aria-selected={option.value === value}
|
|
449
|
+
className={`${css.selectOption}${option.value === value ? ` ${css.selectOptionSelected}` : ''}${index === activeIndex && !closing ? ` ${css.selectOptionActive}` : ''}`}
|
|
450
|
+
onClick={() => { commit(index) }}
|
|
451
|
+
>
|
|
452
|
+
{option.label}
|
|
453
|
+
</div>
|
|
454
|
+
))}
|
|
455
|
+
</div>
|
|
456
|
+
)
|
|
457
|
+
: null}
|
|
458
|
+
</div>
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
|
|
258
462
|
/** A staged boolean field: 继承 / 开 / 关. */
|
|
259
463
|
export function BooleanField(props: FieldProps & {
|
|
260
464
|
/** Copy for the inherit option. */
|
|
@@ -284,17 +488,18 @@ export function BooleanField(props: FieldProps & {
|
|
|
284
488
|
)
|
|
285
489
|
: null}
|
|
286
490
|
</div>
|
|
287
|
-
<
|
|
491
|
+
<SelectField
|
|
288
492
|
id={props.id}
|
|
289
|
-
|
|
493
|
+
options={[
|
|
494
|
+
{ value: '', label: props.inheritLabel },
|
|
495
|
+
{ value: 'true', label: props.onLabel },
|
|
496
|
+
{ value: 'false', label: props.offLabel },
|
|
497
|
+
]}
|
|
290
498
|
value={props.text}
|
|
291
499
|
disabled={props.disabled}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
<option value="true">{props.onLabel}</option>
|
|
296
|
-
<option value="false">{props.offLabel}</option>
|
|
297
|
-
</select>
|
|
500
|
+
invalid={props.invalid}
|
|
501
|
+
onEdit={props.onEdit}
|
|
502
|
+
/>
|
|
298
503
|
<p className={css.hint}>{props.hint}</p>
|
|
299
504
|
</div>
|
|
300
505
|
)
|
|
@@ -327,18 +532,14 @@ export function ChoiceField(props: FieldProps & {
|
|
|
327
532
|
)
|
|
328
533
|
: null}
|
|
329
534
|
</div>
|
|
330
|
-
<
|
|
535
|
+
<SelectField
|
|
331
536
|
id={props.id}
|
|
332
|
-
|
|
537
|
+
options={[{ value: '', label: props.inheritLabel }, ...props.choices]}
|
|
333
538
|
value={props.text}
|
|
334
539
|
disabled={props.disabled}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
{props.choices.map(choice => (
|
|
339
|
-
<option key={choice.value} value={choice.value}>{choice.label}</option>
|
|
340
|
-
))}
|
|
341
|
-
</select>
|
|
540
|
+
invalid={props.invalid}
|
|
541
|
+
onEdit={props.onEdit}
|
|
542
|
+
/>
|
|
342
543
|
<p className={props.invalid ? css.invalid : css.hint}>
|
|
343
544
|
{props.invalid ? props.invalidLabel : props.hint}
|
|
344
545
|
</p>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Task-board settings for availability, agent announcement, and optional Host
|
|
3
|
-
* idle-sleep protection. Registers into the `
|
|
4
|
-
* plugin
|
|
3
|
+
* idle-sleep protection. Registers into the `web-ui.plugin.item` child slot
|
|
4
|
+
* the Web UI plugin group renders, bound to the `task-board` namespace.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
@@ -97,20 +97,20 @@ export function TaskBoardSettingsCard(props: TaskBoardSettingsCardProps) {
|
|
|
97
97
|
const disabled = !state.writable
|
|
98
98
|
const [power, setPower] = useState<TaskBoardPowerSnapshot | undefined>()
|
|
99
99
|
useEffect(() => {
|
|
100
|
+
// The SSE channel already carries power on every real change and pushes
|
|
101
|
+
// one frame on subscribe; polling the full /state snapshot every 5 s
|
|
102
|
+
// re-cloned and re-serialized the whole ledger server-side for one field.
|
|
100
103
|
let live = true
|
|
101
|
-
const
|
|
104
|
+
const events = new EventSource('/api/task-board/events')
|
|
105
|
+
events.onmessage = (message: MessageEvent<string>): void => {
|
|
102
106
|
try {
|
|
103
|
-
const
|
|
104
|
-
if (
|
|
105
|
-
const snapshot = await response.json() as { power?: TaskBoardPowerSnapshot }
|
|
106
|
-
if (live) setPower(snapshot.power)
|
|
107
|
+
const frame = JSON.parse(message.data) as { power?: TaskBoardPowerSnapshot }
|
|
108
|
+
if (frame.power !== undefined && live) setPower(frame.power)
|
|
107
109
|
} catch {
|
|
108
110
|
// The settings form remains usable while the host status is reconnecting.
|
|
109
111
|
}
|
|
110
112
|
}
|
|
111
|
-
|
|
112
|
-
const timer = setInterval(() => { void refresh() }, 5_000)
|
|
113
|
-
return () => { live = false; clearInterval(timer) }
|
|
113
|
+
return () => { live = false; events.close() }
|
|
114
114
|
}, [])
|
|
115
115
|
const fieldProps = {
|
|
116
116
|
overriddenLabel: t('settings.overridden'),
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { memo, useCallback, useEffect, useState } from 'react'
|
|
7
7
|
import { selectedTaskOf, type BoardController } from '../../core/controller.ts'
|
|
8
|
-
import { COLUMNS, type TaskRecord
|
|
9
|
-
import { t
|
|
8
|
+
import { COLUMNS, type TaskRecord } from '../../core/tasks.ts'
|
|
9
|
+
import { t } from '../locales.ts'
|
|
10
10
|
import css from '../board.module.css'
|
|
11
11
|
import { NewTaskModal } from './NewTaskModal.tsx'
|
|
12
12
|
import { STATUS_KEY } from './status-key.ts'
|
|
@@ -50,7 +50,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
50
50
|
const openTask = useCallback((id: string): void => { controller.openTask(id) }, [controller])
|
|
51
51
|
|
|
52
52
|
return (
|
|
53
|
-
<div className={css.board} data-dsh-taskboard-board="">
|
|
53
|
+
<div className={css.board} data-dsh-taskboard-board="" data-dsh-plugin="task-board">
|
|
54
54
|
<header className={css.boardHeader}>
|
|
55
55
|
<button
|
|
56
56
|
type="button"
|
|
@@ -107,7 +107,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
107
107
|
|
|
108
108
|
<div className={css.columns}>
|
|
109
109
|
{archiveView ? (
|
|
110
|
-
<section className={css.column} data-status="archived">
|
|
110
|
+
<section className={css.column} data-status="archived" data-dsh-part="column">
|
|
111
111
|
<header className={css.columnHeader}>
|
|
112
112
|
<h3 className={css.columnTitle}>{t('board.archive')}</h3>
|
|
113
113
|
<span className={css.columnCount}>{visible.length}</span>
|
|
@@ -123,7 +123,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
123
123
|
COLUMNS.map(column => {
|
|
124
124
|
const tasks = visible.filter(task => task.status === column.status)
|
|
125
125
|
return (
|
|
126
|
-
<section key={column.status} className={css.column} data-status={column.status}>
|
|
126
|
+
<section key={column.status} className={css.column} data-status={column.status} data-dsh-part="column">
|
|
127
127
|
<header className={css.columnHeader}>
|
|
128
128
|
<span className={css.statusDot} data-status={column.status} aria-hidden="true" />
|
|
129
129
|
<h3 className={css.columnTitle}>{t(STATUS_KEY[column.status])}</h3>
|
|
@@ -47,6 +47,7 @@ function TaskCardInner({ task, pending, timeZone, onClick }: { task: TaskRecord;
|
|
|
47
47
|
type="button"
|
|
48
48
|
className={css.card}
|
|
49
49
|
data-status={archived ? 'archived' : task.status}
|
|
50
|
+
data-dsh-part="card"
|
|
50
51
|
data-pending={pending || undefined}
|
|
51
52
|
onClick={onClick}
|
|
52
53
|
title={task.description !== '' ? task.description : task.title}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { useEffect, useState } from 'react'
|
|
8
8
|
import type { BoardController } from '../../core/controller.ts'
|
|
9
9
|
import { isValidCron } from '../../core/schedule.ts'
|
|
10
|
-
import { MANUAL_STATUSES, TASK_PERMISSIONS, type ExecutionRecord, type TaskPermission, type TaskRecord
|
|
10
|
+
import { MANUAL_STATUSES, TASK_PERMISSIONS, type ExecutionRecord, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
|
|
11
11
|
import { t, type TaskBoardKey } from '../locales.ts'
|
|
12
12
|
import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
|
|
13
13
|
import css from '../board.module.css'
|
|
@@ -49,6 +49,7 @@ export function mountBoard(controller: BoardController): () => void {
|
|
|
49
49
|
if (column === undefined) return
|
|
50
50
|
container = document.createElement('div')
|
|
51
51
|
container.dataset.dshTaskboardView = ''
|
|
52
|
+
container.dataset.dshPlugin = 'task-board'
|
|
52
53
|
container.className = css.boardView
|
|
53
54
|
column.appendChild(container)
|
|
54
55
|
root = createRoot(container)
|
package/src/client/host-api.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
TASK_BOARD_API_PREFIX,
|
|
4
4
|
type TaskBoardAction,
|
|
5
5
|
type TaskBoardActionEnvelope,
|
|
6
|
+
type TaskBoardEventPayload,
|
|
6
7
|
type TaskBoardSnapshot,
|
|
7
8
|
} from '../protocol.ts'
|
|
8
9
|
|
|
@@ -25,7 +26,7 @@ export interface TaskBoardHostTransport {
|
|
|
25
26
|
bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>
|
|
26
27
|
state(): Promise<TaskBoardSnapshot>
|
|
27
28
|
action(action: TaskBoardAction): Promise<TaskBoardSnapshot>
|
|
28
|
-
subscribe(listener: () => void): () => void
|
|
29
|
+
subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
export class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
|
|
@@ -82,9 +83,17 @@ export class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
|
|
|
82
83
|
}
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
subscribe(listener: () => void): () => void {
|
|
86
|
+
subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void {
|
|
86
87
|
const events = new EventSource(`${TASK_BOARD_API_PREFIX}/events`)
|
|
87
|
-
events.onmessage = () => {
|
|
88
|
+
events.onmessage = (message: MessageEvent<string>): void => {
|
|
89
|
+
try {
|
|
90
|
+
const parsed = JSON.parse(message.data) as TaskBoardEventPayload
|
|
91
|
+
if (parsed === null || typeof parsed !== 'object' || typeof parsed.revision !== 'number') throw new Error('invalid event frame')
|
|
92
|
+
listener(parsed)
|
|
93
|
+
} catch {
|
|
94
|
+
listener()
|
|
95
|
+
}
|
|
96
|
+
}
|
|
88
97
|
const onVisible = (): void => { if (document.visibilityState === 'visible') listener() }
|
|
89
98
|
document.addEventListener('visibilitychange', onVisible)
|
|
90
99
|
return () => {
|
|
@@ -290,6 +290,92 @@
|
|
|
290
290
|
border-color: var(--dsw-alias-label-error);
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
.selectWrap {
|
|
294
|
+
position: relative;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.selectButton {
|
|
298
|
+
appearance: none;
|
|
299
|
+
width: 100%;
|
|
300
|
+
text-align: left;
|
|
301
|
+
cursor: pointer;
|
|
302
|
+
align-items: center;
|
|
303
|
+
justify-content: space-between;
|
|
304
|
+
gap: 8px;
|
|
305
|
+
display: flex;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.selectLabel {
|
|
309
|
+
min-width: 0;
|
|
310
|
+
overflow: hidden;
|
|
311
|
+
text-overflow: ellipsis;
|
|
312
|
+
white-space: nowrap;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.selectChevron {
|
|
316
|
+
color: var(--dsw-alias-label-tertiary);
|
|
317
|
+
flex: none;
|
|
318
|
+
transition: transform 0.16s;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.selectChevronOpen {
|
|
322
|
+
transform: rotate(180deg);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.selectPopup {
|
|
326
|
+
position: absolute;
|
|
327
|
+
top: calc(100% + 4px);
|
|
328
|
+
left: 0;
|
|
329
|
+
right: 0;
|
|
330
|
+
z-index: 40;
|
|
331
|
+
flex-direction: column;
|
|
332
|
+
max-height: 240px;
|
|
333
|
+
overflow-y: auto;
|
|
334
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
335
|
+
border-radius: 8px;
|
|
336
|
+
background: var(--dsw-alias-bg-layer-3);
|
|
337
|
+
box-shadow: 0 8px 24px var(--dsw-alias-bg-mask-2);
|
|
338
|
+
padding: 4px;
|
|
339
|
+
opacity: 0;
|
|
340
|
+
transform: translateY(-4px);
|
|
341
|
+
transition: opacity 0.1s ease, transform 0.1s ease;
|
|
342
|
+
display: flex;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
.selectPopupOpen {
|
|
346
|
+
opacity: 1;
|
|
347
|
+
transform: none;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
.selectPopupClose {
|
|
351
|
+
opacity: 0;
|
|
352
|
+
transform: translateY(-4px);
|
|
353
|
+
pointer-events: none;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.selectOption {
|
|
357
|
+
border-radius: 6px;
|
|
358
|
+
padding: 6px 10px;
|
|
359
|
+
font-size: 13px;
|
|
360
|
+
line-height: 1.5;
|
|
361
|
+
color: var(--dsw-alias-label-primary);
|
|
362
|
+
cursor: pointer;
|
|
363
|
+
white-space: nowrap;
|
|
364
|
+
overflow: hidden;
|
|
365
|
+
text-overflow: ellipsis;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.selectOption:hover,
|
|
369
|
+
.selectOptionActive {
|
|
370
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.selectOptionSelected {
|
|
374
|
+
color: var(--dsw-alias-brand-primary);
|
|
375
|
+
font-weight: 500;
|
|
376
|
+
background: color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 10%, transparent);
|
|
377
|
+
}
|
|
378
|
+
|
|
293
379
|
.invalid {
|
|
294
380
|
color: var(--dsw-alias-label-error);
|
|
295
381
|
margin: 0;
|
|
@@ -310,7 +396,10 @@
|
|
|
310
396
|
.chevron,
|
|
311
397
|
.chevronOpen,
|
|
312
398
|
.discard,
|
|
313
|
-
.save
|
|
399
|
+
.save,
|
|
400
|
+
.selectChevron,
|
|
401
|
+
.selectChevronOpen,
|
|
402
|
+
.selectPopup {
|
|
314
403
|
transition: none;
|
|
315
404
|
}
|
|
316
405
|
}
|