@linxin666/dsh-client-ui-task-board 0.4.4 → 0.4.6
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 +59 -20
- package/README.zh.md +59 -20
- package/lib/client.js +6208 -624
- package/lib/client.js.map +1 -1
- package/lib/index.js +4774 -493
- package/lib/types/client/PluginSettingsCard.d.ts +7 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +31 -3
- package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
- package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
- package/lib/types/client/board/EditTaskModal.d.ts +5 -2
- package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
- package/lib/types/client/board/NewTaskModal.d.ts +4 -1
- package/lib/types/client/board/TagManagerModal.d.ts +26 -0
- package/lib/types/client/board/TaskBoard.d.ts +10 -1
- package/lib/types/client/board/TaskCard.d.ts +14 -1
- package/lib/types/client/board/TaskDetail.d.ts +3 -1
- package/lib/types/client/board/TaskForm.d.ts +4 -1
- package/lib/types/client/board/VerificationReport.d.ts +23 -0
- package/lib/types/client/board/card-view.d.ts +113 -0
- package/lib/types/client/board/icons.d.ts +32 -0
- package/lib/types/client/board/overlay.d.ts +69 -0
- package/lib/types/client/board/status-key.d.ts +15 -0
- package/lib/types/client/board/task-markdown.d.ts +19 -0
- package/lib/types/client/index.d.ts +23 -5
- package/lib/types/client/locales.d.ts +168 -1
- package/lib/types/client/native-panel.d.ts +6 -20
- package/lib/types/client/plugin-card-seat.d.ts +5 -0
- package/lib/types/client/schedule-zone.d.ts +50 -0
- package/lib/types/client/seats.d.ts +18 -0
- package/lib/types/client/service.d.ts +32 -0
- package/lib/types/client/settings-form.d.ts +16 -0
- package/lib/types/core/controller.d.ts +46 -11
- package/lib/types/core/extension.d.ts +283 -0
- package/lib/types/core/handover.d.ts +13 -1
- package/lib/types/core/issue-form.d.ts +16 -0
- package/lib/types/core/poll-cadence.d.ts +31 -0
- package/lib/types/core/tasks.d.ts +228 -17
- package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
- package/lib/types/core/use-cases/task-tag.d.ts +48 -0
- package/lib/types/core/use-cases/task-update.d.ts +5 -3
- package/lib/types/core/verification-budget.d.ts +52 -0
- package/lib/types/core/verification.d.ts +753 -0
- package/lib/types/core/workspace-target.d.ts +41 -0
- package/lib/types/host/agent-tools.d.ts +1 -1
- package/lib/types/host/extension-registry.d.ts +111 -0
- package/lib/types/host/llm-dispatch.d.ts +63 -0
- package/lib/types/host/verification-gate.d.ts +95 -0
- package/lib/types/host/verification-runner.d.ts +139 -0
- package/lib/types/host/workspace-evidence.d.ts +53 -0
- package/lib/types/host-ledger.d.ts +133 -14
- package/lib/types/host-runner.d.ts +55 -10
- package/lib/types/host-service.d.ts +205 -11
- package/lib/types/index.d.ts +134 -26
- package/lib/types/protocol.d.ts +92 -10
- package/lib/types/tool-surface.d.ts +71 -0
- package/package.json +28 -31
- package/src/client/PluginSettingsCard.tsx +9 -0
- package/src/client/TaskBoardSettingsCard.tsx +378 -66
- package/src/client/board/ClampedMarkdown.tsx +69 -0
- package/src/client/board/ConfirmDialog.tsx +7 -3
- package/src/client/board/EditTaskModal.tsx +5 -2
- package/src/client/board/LinkSubtaskModal.tsx +12 -7
- package/src/client/board/NewTaskModal.tsx +168 -21
- package/src/client/board/TagManagerModal.tsx +235 -0
- package/src/client/board/TaskBoard.tsx +259 -103
- package/src/client/board/TaskCard.tsx +214 -81
- package/src/client/board/TaskDetail.tsx +289 -43
- package/src/client/board/TaskForm.tsx +13 -3
- package/src/client/board/VerificationReport.tsx +322 -0
- package/src/client/board/card-view.ts +220 -0
- package/src/client/board/icons.tsx +89 -0
- package/src/client/board/overlay.tsx +189 -0
- package/src/client/board/status-key.ts +30 -0
- package/src/client/board/task-markdown.module.css +137 -0
- package/src/client/board/task-markdown.tsx +106 -0
- package/src/client/board-settings.module.css +66 -0
- package/src/client/board.module.css +488 -63
- package/src/client/index.ts +82 -51
- package/src/client/locales.ts +343 -6
- package/src/client/native-panel.tsx +18 -2
- package/src/client/plugin-card-seat.ts +7 -0
- package/src/client/schedule-zone.ts +100 -2
- package/src/client/seats.tsx +58 -0
- package/src/client/service.ts +94 -0
- package/src/client/settings-card.module.css +31 -5
- package/src/client/settings-form.ts +27 -1
- package/src/core/controller.ts +105 -10
- package/src/core/extension.ts +376 -0
- package/src/core/freeze-snapshot.ts +2 -2
- package/src/core/handover.ts +16 -1
- package/src/core/issue-form.ts +19 -0
- package/src/core/poll-cadence.ts +41 -0
- package/src/core/store.ts +80 -15
- package/src/core/tasks.ts +293 -45
- package/src/core/use-cases/task-create.ts +30 -9
- package/src/core/use-cases/task-parent.ts +3 -2
- package/src/core/use-cases/task-schedule.ts +190 -25
- package/src/core/use-cases/task-tag.ts +110 -0
- package/src/core/use-cases/task-update.ts +13 -7
- package/src/core/verification-budget.ts +115 -0
- package/src/core/verification.ts +1594 -0
- package/src/core/workspace-target.ts +59 -0
- package/src/host/agent-tools.ts +125 -38
- package/src/host/extension-registry.ts +509 -0
- package/src/host/llm-dispatch.ts +87 -0
- package/src/host/verification-gate.ts +487 -0
- package/src/host/verification-runner.ts +702 -0
- package/src/host/workspace-evidence.ts +176 -0
- package/src/host-ai.ts +15 -9
- package/src/host-ledger.ts +468 -71
- package/src/host-routes.ts +15 -2
- package/src/host-runner.ts +106 -24
- package/src/host-service.ts +546 -40
- package/src/index.ts +305 -21
- package/src/protocol.ts +226 -28
- package/src/tool-surface.ts +95 -0
- package/lib/invariant.js +0 -6
- package/lib/types/invariant.d.ts +0 -3
- package/src/invariant.ts +0 -4
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The board's own vector glyphs.
|
|
3
|
+
*
|
|
4
|
+
* Every control glyph the board draws comes from here, so one action always
|
|
5
|
+
* has one metaphor and one stroke recipe: a 16-unit viewBox, `currentColor`,
|
|
6
|
+
* 1.3 stroke, round caps and joins — the same recipe the sidebar panel glyph
|
|
7
|
+
* (`native-panel.tsx`) uses, which is why the board's chrome and its sidebar
|
|
8
|
+
* row read as one product. Icon-only controls pass a localized `title` and an
|
|
9
|
+
* accessible name at the call site; the glyph itself is always decorative.
|
|
10
|
+
*
|
|
11
|
+
* @module @linxin666/dsh-client-ui-task-board/client/board/icons
|
|
12
|
+
*/
|
|
13
|
+
import type { ReactNode, ReactElement } from 'react'
|
|
14
|
+
|
|
15
|
+
/** Stroke geometry shared by every board glyph. */
|
|
16
|
+
const GLYPH = {
|
|
17
|
+
viewBox: '0 0 16 16',
|
|
18
|
+
fill: 'none',
|
|
19
|
+
stroke: 'currentColor',
|
|
20
|
+
strokeWidth: 1.3,
|
|
21
|
+
strokeLinecap: 'round',
|
|
22
|
+
strokeLinejoin: 'round',
|
|
23
|
+
'aria-hidden': true,
|
|
24
|
+
} as const
|
|
25
|
+
|
|
26
|
+
/** Props of one board glyph: the shared recipe plus an explicit pixel size. */
|
|
27
|
+
export interface BoardIconProps {
|
|
28
|
+
/** Rendered square edge in CSS pixels. */
|
|
29
|
+
size?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Render one board glyph through the shared stroke recipe. */
|
|
33
|
+
function Glyph({ size = 14, children }: BoardIconProps & { children: ReactNode }): ReactElement {
|
|
34
|
+
return (
|
|
35
|
+
<svg {...GLYPH} width={size} height={size}>
|
|
36
|
+
{children}
|
|
37
|
+
</svg>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Leave the board: the back-to-chat chevron. */
|
|
42
|
+
export function IconChevronLeft(props: BoardIconProps): ReactElement {
|
|
43
|
+
return <Glyph {...props}><path d="M10 3.5 5.5 8 10 12.5" /></Glyph>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Dismiss a transient surface. */
|
|
47
|
+
export function IconClose(props: BoardIconProps): ReactElement {
|
|
48
|
+
return <Glyph {...props}><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" /></Glyph>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Add: create a task, a label row, or a subtask. */
|
|
52
|
+
export function IconPlus(props: BoardIconProps): ReactElement {
|
|
53
|
+
return <Glyph {...props}><path d="M8 3.5v9M3.5 8h9" /></Glyph>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Open the execution's session transcript. */
|
|
57
|
+
export function IconSession(props: BoardIconProps): ReactElement {
|
|
58
|
+
return (
|
|
59
|
+
<Glyph {...props}>
|
|
60
|
+
<rect x="2.2" y="3.2" width="11.6" height="9.6" rx="1.6" />
|
|
61
|
+
<path d="M5.3 6.6l1.9 1.7-1.9 1.7M8.8 10h2.4" />
|
|
62
|
+
</Glyph>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Switch a column to single-line cards. */
|
|
67
|
+
export function IconCompactRows(props: BoardIconProps): ReactElement {
|
|
68
|
+
return <Glyph {...props}><path d="M3 4.5h10M3 8h10M3 11.5h10" /></Glyph>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Switch a column back to full cards. */
|
|
72
|
+
export function IconExpandRows(props: BoardIconProps): ReactElement {
|
|
73
|
+
return (
|
|
74
|
+
<Glyph {...props}>
|
|
75
|
+
<rect x="2.6" y="2.6" width="10.8" height="4.4" rx="1.2" />
|
|
76
|
+
<rect x="2.6" y="9" width="10.8" height="4.4" rx="1.2" />
|
|
77
|
+
</Glyph>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A scheduled run: the recurrence mark on a card. */
|
|
82
|
+
export function IconClock(props: BoardIconProps): ReactElement {
|
|
83
|
+
return (
|
|
84
|
+
<Glyph {...props}>
|
|
85
|
+
<circle cx="8" cy="8" r="5.4" />
|
|
86
|
+
<path d="M8 5.2V8l2 1.4" />
|
|
87
|
+
</Glyph>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared behavior of the board's transient surfaces (the task detail overlay
|
|
3
|
+
* and every modal that opens above it).
|
|
4
|
+
*
|
|
5
|
+
* Two concerns live here because they are one contract:
|
|
6
|
+
*
|
|
7
|
+
* - `usePresence` keeps a surface mounted through its exit leg. The board's
|
|
8
|
+
* overlays are mounted by a boolean at the call site; the moment that
|
|
9
|
+
* boolean flips the node would vanish, so the enter animation had no
|
|
10
|
+
* reverse. Presence turns the boolean into `open` -> `closing` -> gone and
|
|
11
|
+
* paints the phase on the surface, which is what the CSS transition pair
|
|
12
|
+
* reads. Dimensions never change across the pair, so nothing around the
|
|
13
|
+
* overlay reflows.
|
|
14
|
+
* - `useDialog` owns the keyboard and focus contract of a modal surface:
|
|
15
|
+
* focus moves into the dialog when it opens and returns to the control that
|
|
16
|
+
* opened it when it closes, Tab cycles inside it, and Escape asks the owner
|
|
17
|
+
* to close.
|
|
18
|
+
*
|
|
19
|
+
* @module @linxin666/dsh-client-ui-task-board/client/board/overlay
|
|
20
|
+
*/
|
|
21
|
+
import { useCallback, useEffect, useRef, useState, type MouseEvent } from 'react'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Exit duration in milliseconds. It matches `--dsh-tb-motion-close` in
|
|
25
|
+
* `board.module.css`; under `prefers-reduced-motion` the stylesheet keeps the
|
|
26
|
+
* same duration and drops only the spatial travel, so the unmount delay stays
|
|
27
|
+
* valid for both motion settings.
|
|
28
|
+
*/
|
|
29
|
+
export const OVERLAY_EXIT_MS = 140
|
|
30
|
+
|
|
31
|
+
/** Lifecycle phase of one transient surface. */
|
|
32
|
+
export type OverlayPhase = 'open' | 'closing'
|
|
33
|
+
|
|
34
|
+
/** Presence of one transient surface. */
|
|
35
|
+
export interface OverlayPresence {
|
|
36
|
+
/** Whether the surface should render at all (true through the exit leg). */
|
|
37
|
+
mounted: boolean
|
|
38
|
+
/** Which leg of the motion pair the surface is on. */
|
|
39
|
+
phase: OverlayPhase
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Keep a surface mounted until its exit transition has run.
|
|
44
|
+
*
|
|
45
|
+
* `open=false` starts the exit leg immediately (the surface paints
|
|
46
|
+
* `data-state="closing"`) and unmounts it one exit duration later. Re-opening
|
|
47
|
+
* during the exit leg cancels the unmount, so a fast toggle never leaves a
|
|
48
|
+
* half-faded surface behind.
|
|
49
|
+
* @param open - the owner's mount request.
|
|
50
|
+
* @param exitMs - exit duration; defaults to {@link OVERLAY_EXIT_MS}.
|
|
51
|
+
* @returns the mount decision and the phase to paint.
|
|
52
|
+
*/
|
|
53
|
+
export function usePresence(open: boolean, exitMs: number = OVERLAY_EXIT_MS): OverlayPresence {
|
|
54
|
+
const [presence, setPresence] = useState<OverlayPresence>(
|
|
55
|
+
() => ({ mounted: open, phase: open ? 'open' : 'closing' }),
|
|
56
|
+
)
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (open) {
|
|
59
|
+
setPresence(current => (current.mounted && current.phase === 'open' ? current : { mounted: true, phase: 'open' }))
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
setPresence(current => (current.mounted ? { mounted: true, phase: 'closing' } : current))
|
|
63
|
+
const timer = setTimeout(() => { setPresence({ mounted: false, phase: 'closing' }) }, exitMs)
|
|
64
|
+
return () => { clearTimeout(timer) }
|
|
65
|
+
}, [open, exitMs])
|
|
66
|
+
return presence
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Tab-order selector of the elements a modal surface hands focus to. */
|
|
70
|
+
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Open modal surfaces in mount order. Keyboard handling belongs to the last
|
|
74
|
+
* entry only: the board nests modals (a confirm or an edit form above the task
|
|
75
|
+
* detail), and without the stack one Escape press would close both layers.
|
|
76
|
+
*/
|
|
77
|
+
const MODAL_STACK: symbol[] = []
|
|
78
|
+
|
|
79
|
+
/** The surface that currently owns the keyboard, if any. */
|
|
80
|
+
function topMostDialog(): symbol | undefined {
|
|
81
|
+
return MODAL_STACK[MODAL_STACK.length - 1]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The dialog's keyboard/focus handle. */
|
|
85
|
+
export interface DialogHandle<T extends HTMLElement> {
|
|
86
|
+
/** Callback ref for the dialog element itself (it must accept focus). */
|
|
87
|
+
attach: (node: T | null) => void
|
|
88
|
+
/** Backdrop press handler: closes only on a press of the backdrop itself. */
|
|
89
|
+
onMouseDown: (event: MouseEvent<HTMLElement>) => void
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Bind a modal surface's keyboard and focus contract.
|
|
94
|
+
*
|
|
95
|
+
* Focus enters the dialog on mount (its first focusable control, or the dialog
|
|
96
|
+
* itself) and returns to the control that opened it on unmount. Tab wraps
|
|
97
|
+
* inside the dialog, and Escape asks the owner to close — but only while the
|
|
98
|
+
* surface is on its enter leg, so a repeat press during the exit cannot queue
|
|
99
|
+
* a second close.
|
|
100
|
+
* @param onClose - the owner's close request.
|
|
101
|
+
* @param phase - the surface's current motion phase.
|
|
102
|
+
* @returns the ref to attach and the backdrop press handler.
|
|
103
|
+
*/
|
|
104
|
+
export function useDialog<T extends HTMLElement>(onClose: () => void, phase: OverlayPhase): DialogHandle<T> {
|
|
105
|
+
const nodeRef = useRef<T | null>(null)
|
|
106
|
+
const openerRef = useRef<HTMLElement | null>(null)
|
|
107
|
+
const [attached, setAttached] = useState(false)
|
|
108
|
+
const closeRef = useRef(onClose)
|
|
109
|
+
closeRef.current = onClose
|
|
110
|
+
// Identity in the document-wide stack of open surfaces (see MODAL_STACK).
|
|
111
|
+
const [token] = useState(() => Symbol('board-dialog'))
|
|
112
|
+
|
|
113
|
+
/** Callback ref: records the node and triggers the initial focus pass. */
|
|
114
|
+
const attach = useCallback((node: T | null): void => {
|
|
115
|
+
nodeRef.current = node
|
|
116
|
+
setAttached(node !== null)
|
|
117
|
+
}, [])
|
|
118
|
+
|
|
119
|
+
// Focus enters the surface on its first attach; the element that opened it
|
|
120
|
+
// is remembered and gets focus back when the surface unmounts, so a keyboard
|
|
121
|
+
// user returns to the control they came from instead of to the document.
|
|
122
|
+
//
|
|
123
|
+
// A surface that already landed focus inside itself keeps it: React's own
|
|
124
|
+
// `autoFocus` (the create form's title field) commits before this pass and
|
|
125
|
+
// names the field the user actually starts on. Otherwise focus goes to the
|
|
126
|
+
// surface itself, which announces the dialog and its accessible name; the
|
|
127
|
+
// first Tab then reaches its first control.
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (!attached) return
|
|
130
|
+
const opener = document.activeElement
|
|
131
|
+
openerRef.current = opener instanceof HTMLElement ? opener : null
|
|
132
|
+
const node = nodeRef.current
|
|
133
|
+
if (node === null) return
|
|
134
|
+
if (opener instanceof HTMLElement && node.contains(opener)) return
|
|
135
|
+
const target = node.querySelector<HTMLElement>('[data-dsh-autofocus]')
|
|
136
|
+
;(target ?? node).focus()
|
|
137
|
+
}, [attached])
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
MODAL_STACK.push(token)
|
|
141
|
+
return () => {
|
|
142
|
+
const index = MODAL_STACK.indexOf(token)
|
|
143
|
+
if (index >= 0) MODAL_STACK.splice(index, 1)
|
|
144
|
+
const restore = openerRef.current
|
|
145
|
+
if (restore !== null && restore.isConnected) restore.focus()
|
|
146
|
+
}
|
|
147
|
+
}, [token])
|
|
148
|
+
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
151
|
+
// Only the topmost surface answers: a modal opened from the detail view
|
|
152
|
+
// must swallow Escape and Tab itself instead of closing both layers.
|
|
153
|
+
if (topMostDialog() !== token) return
|
|
154
|
+
if (event.key === 'Escape') {
|
|
155
|
+
if (phase === 'closing') return
|
|
156
|
+
event.stopPropagation()
|
|
157
|
+
closeRef.current()
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
if (event.key !== 'Tab') return
|
|
161
|
+
const node = nodeRef.current
|
|
162
|
+
if (node === null) return
|
|
163
|
+
const focusable = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE))
|
|
164
|
+
if (focusable.length === 0) return
|
|
165
|
+
const first = focusable[0]
|
|
166
|
+
const last = focusable[focusable.length - 1]
|
|
167
|
+
const active = document.activeElement
|
|
168
|
+
if (event.shiftKey) {
|
|
169
|
+
if (active === first || active === node) {
|
|
170
|
+
event.preventDefault()
|
|
171
|
+
last.focus()
|
|
172
|
+
}
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
if (active === last) {
|
|
176
|
+
event.preventDefault()
|
|
177
|
+
first.focus()
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
document.addEventListener('keydown', onKeyDown, true)
|
|
181
|
+
return () => { document.removeEventListener('keydown', onKeyDown, true) }
|
|
182
|
+
}, [phase, token])
|
|
183
|
+
|
|
184
|
+
const onMouseDown = useCallback((event: MouseEvent<HTMLElement>): void => {
|
|
185
|
+
if (event.target === event.currentTarget) closeRef.current()
|
|
186
|
+
}, [])
|
|
187
|
+
|
|
188
|
+
return { attach, onMouseDown }
|
|
189
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TaskStatus } from '../../core/tasks.ts'
|
|
2
|
+
import { verificationPhase, type ExecutionVerification, type VerificationPhase } from '../../core/verification.ts'
|
|
2
3
|
import type { TaskBoardKey } from '../locales.ts'
|
|
3
4
|
|
|
4
5
|
/** Task status → locale key (board column titles and the detail badge). */
|
|
@@ -9,3 +10,32 @@ export const STATUS_KEY: Record<TaskStatus, TaskBoardKey> = {
|
|
|
9
10
|
done: 'board.status.done',
|
|
10
11
|
failed: 'board.status.failed',
|
|
11
12
|
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Acceptance phase to the label the running column shows: executing,
|
|
16
|
+
* verifying, or fixing a failed acceptance. The card keeps one running label;
|
|
17
|
+
* this decides which one.
|
|
18
|
+
*/
|
|
19
|
+
export const VERIFICATION_PHASE_KEY: Record<VerificationPhase, TaskBoardKey> = {
|
|
20
|
+
off: 'running.executing',
|
|
21
|
+
executing: 'running.executing',
|
|
22
|
+
verifying: 'running.verifying',
|
|
23
|
+
repairing: 'running.repairing',
|
|
24
|
+
passed: 'running.verificationPassed',
|
|
25
|
+
failed: 'running.verificationFailed',
|
|
26
|
+
// Held for a human: the judge produced no usable veto, so this is NOT the
|
|
27
|
+
// quality-failure label.
|
|
28
|
+
invalid: 'running.verificationInvalid',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The acceptance label for one open execution, or undefined when acceptance
|
|
33
|
+
* does not govern it (the switch was off at start, the run never became a goal
|
|
34
|
+
* run, or a teammate is covered by its Lead).
|
|
35
|
+
* @param verification - the execution's acceptance state.
|
|
36
|
+
* @returns the locale key to render, or undefined for the historical label.
|
|
37
|
+
*/
|
|
38
|
+
export function verificationRunningKey(verification: ExecutionVerification | undefined): TaskBoardKey | undefined {
|
|
39
|
+
if (verification === undefined || verification.applicability !== 'enforced') return undefined
|
|
40
|
+
return VERIFICATION_PHASE_KEY[verificationPhase(verification)]
|
|
41
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Styles for task detail safe Markdown rendering.
|
|
3
|
+
* Respects dsh --dsw-* theme tokens.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
.markdownRoot {
|
|
7
|
+
font-size: 13px;
|
|
8
|
+
line-height: 1.55;
|
|
9
|
+
color: var(--dsw-alias-label-primary);
|
|
10
|
+
overflow-wrap: anywhere;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.markdownRoot > :first-child {
|
|
14
|
+
margin-top: 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.markdownRoot > :last-child {
|
|
18
|
+
margin-bottom: 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.paragraph {
|
|
22
|
+
margin: 0 0 8px;
|
|
23
|
+
line-height: 1.55;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.heading1,
|
|
27
|
+
.heading2,
|
|
28
|
+
.heading3,
|
|
29
|
+
.heading4,
|
|
30
|
+
.heading5,
|
|
31
|
+
.heading6 {
|
|
32
|
+
margin: 12px 0 6px;
|
|
33
|
+
font-weight: 600;
|
|
34
|
+
color: var(--dsw-alias-label-primary);
|
|
35
|
+
line-height: 1.35;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.heading1 { font-size: 16px; }
|
|
39
|
+
.heading2 { font-size: 15px; }
|
|
40
|
+
.heading3 { font-size: 14px; }
|
|
41
|
+
.heading4 { font-size: 13px; }
|
|
42
|
+
.heading5 { font-size: 12.5px; }
|
|
43
|
+
.heading6 { font-size: 12px; }
|
|
44
|
+
|
|
45
|
+
.list {
|
|
46
|
+
margin: 0 0 8px;
|
|
47
|
+
padding-left: 20px;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.listItem {
|
|
51
|
+
margin: 2px 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.taskItem {
|
|
55
|
+
list-style-type: none;
|
|
56
|
+
margin-left: -18px;
|
|
57
|
+
display: flex;
|
|
58
|
+
align-items: baseline;
|
|
59
|
+
gap: 6px;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.taskCheckbox {
|
|
63
|
+
margin: 0;
|
|
64
|
+
cursor: default;
|
|
65
|
+
vertical-align: middle;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.codeBlock {
|
|
69
|
+
margin: 8px 0;
|
|
70
|
+
padding: 8px 10px;
|
|
71
|
+
font-size: 12px;
|
|
72
|
+
line-height: 1.45;
|
|
73
|
+
font-family: var(--dsw-font-markdown-code-block-small, monospace);
|
|
74
|
+
color: var(--dsw-alias-label-primary);
|
|
75
|
+
background: var(--dsw-alias-markdown-code-block, rgba(0, 0, 0, 0.05));
|
|
76
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.1));
|
|
77
|
+
border-radius: 6px;
|
|
78
|
+
overflow-x: auto;
|
|
79
|
+
white-space: pre-wrap;
|
|
80
|
+
overflow-wrap: anywhere;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.inlineCode {
|
|
84
|
+
padding: 1px 4px;
|
|
85
|
+
font-size: 12px;
|
|
86
|
+
font-family: var(--dsw-font-markdown-code-block-small, monospace);
|
|
87
|
+
color: var(--dsw-alias-label-primary);
|
|
88
|
+
background: var(--dsw-alias-markdown-code-block, rgba(0, 0, 0, 0.05));
|
|
89
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.1));
|
|
90
|
+
border-radius: 4px;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.link {
|
|
94
|
+
color: var(--dsw-alias-brand-primary, #3b82f6);
|
|
95
|
+
text-decoration: underline;
|
|
96
|
+
text-underline-offset: 2px;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.link:hover {
|
|
100
|
+
opacity: 0.85;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.imagePlaceholder {
|
|
104
|
+
display: inline-block;
|
|
105
|
+
padding: 2px 6px;
|
|
106
|
+
font-size: 11px;
|
|
107
|
+
color: var(--dsw-alias-label-secondary, #666);
|
|
108
|
+
background: var(--dsw-alias-bg-subtle, rgba(0, 0, 0, 0.04));
|
|
109
|
+
border: 1px dashed var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.15));
|
|
110
|
+
border-radius: 4px;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.blockquote {
|
|
114
|
+
margin: 8px 0;
|
|
115
|
+
padding-left: 10px;
|
|
116
|
+
border-left: 3px solid var(--dsw-alias-border-l1, #ccc);
|
|
117
|
+
color: var(--dsw-alias-label-secondary, #666);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.table {
|
|
121
|
+
border-collapse: collapse;
|
|
122
|
+
width: 100%;
|
|
123
|
+
margin: 8px 0;
|
|
124
|
+
font-size: 12.5px;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.table th,
|
|
128
|
+
.table td {
|
|
129
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.1));
|
|
130
|
+
padding: 6px 8px;
|
|
131
|
+
text-align: left;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.table th {
|
|
135
|
+
background: var(--dsw-alias-bg-subtle, rgba(0, 0, 0, 0.03));
|
|
136
|
+
font-weight: 600;
|
|
137
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Fragment, createElement, useMemo, type ReactNode } from 'react'
|
|
2
|
+
import { Lexer, type Token, type Tokens } from 'marked'
|
|
3
|
+
import { EMPTY_FIELD, HEADING_COLON, SUMMARY_HEADING } from '../../core/issue-form.ts'
|
|
4
|
+
import css from './task-markdown.module.css'
|
|
5
|
+
|
|
6
|
+
function safeHref(value: string): string | undefined {
|
|
7
|
+
try {
|
|
8
|
+
const url = new URL(value)
|
|
9
|
+
return ['http:', 'https:', 'mailto:'].includes(url.protocol) ? url.href : undefined
|
|
10
|
+
} catch { return undefined }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function plain(tokens: Token[], separator = ''): string {
|
|
14
|
+
return tokens.map(token => {
|
|
15
|
+
if (token.type === 'html' || token.type === 'def' || token.type === 'hr') return ''
|
|
16
|
+
if (token.type === 'list') return (token as Tokens.List).items.map(item => plain(item.tokens)).join(' ')
|
|
17
|
+
if (token.type === 'table') {
|
|
18
|
+
const table = token as Tokens.Table
|
|
19
|
+
return [table.header, ...table.rows].map(row => row.map(cell => plain(cell.tokens)).join(' ')).join(' ')
|
|
20
|
+
}
|
|
21
|
+
if ('tokens' in token && Array.isArray(token.tokens)) return plain(token.tokens)
|
|
22
|
+
return 'text' in token ? String(token.text) : ' '
|
|
23
|
+
}).join(separator)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Readable summary without changing the task source or creating DOM HTML. */
|
|
27
|
+
export function markdownToPlainText(source: string): string {
|
|
28
|
+
return plain(Lexer.lex(source, { gfm: true }), ' ').replace(/\s+/g, ' ').trim()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Card excerpt: the plain text a reader can tell cards apart by.
|
|
34
|
+
*
|
|
35
|
+
* Issue-form bodies open with identical boilerplate (a duplicate-search
|
|
36
|
+
* checkbox, a plugin field, an issue-type field), so the plain-text prefix of
|
|
37
|
+
* every synced card read the same. The excerpt drops section headings, lists
|
|
38
|
+
* made only of checkboxes, raw HTML, rules and empty-field placeholders; when a
|
|
39
|
+
* summary-like section exists its body wins. A description that loses
|
|
40
|
+
* everything to those rules falls back to its full plain text, so no card goes
|
|
41
|
+
* blank. The stored description is never changed.
|
|
42
|
+
* @param source - the task description (Markdown).
|
|
43
|
+
*/
|
|
44
|
+
export function markdownExcerpt(source: string): string {
|
|
45
|
+
const blocks: string[] = []
|
|
46
|
+
const summary: string[] = []
|
|
47
|
+
let inSummary = false
|
|
48
|
+
for (const token of Lexer.lex(source, { gfm: true })) {
|
|
49
|
+
if (token.type === 'heading') {
|
|
50
|
+
inSummary = SUMMARY_HEADING.test(plain([token]).replace(HEADING_COLON, '').trim())
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
if (token.type === 'space' || token.type === 'html' || token.type === 'hr' || token.type === 'def') continue
|
|
54
|
+
if (token.type === 'list' && (token as Tokens.List).items.every(item => item.task)) continue
|
|
55
|
+
const text = plain([token], ' ').replace(/\s+/g, ' ').trim()
|
|
56
|
+
if (text === '' || EMPTY_FIELD.test(text)) continue
|
|
57
|
+
;(inSummary ? summary : blocks).push(text)
|
|
58
|
+
}
|
|
59
|
+
const picked = (summary.length > 0 ? summary : blocks).join(' ').trim()
|
|
60
|
+
return picked === '' ? markdownToPlainText(source) : picked
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function renderTokens(tokens: Token[]): ReactNode {
|
|
64
|
+
return tokens.map((token, index) => {
|
|
65
|
+
const children = 'tokens' in token && Array.isArray(token.tokens) ? renderTokens(token.tokens) : 'text' in token ? String(token.text) : null
|
|
66
|
+
let node: ReactNode
|
|
67
|
+
switch (token.type) {
|
|
68
|
+
case 'space': case 'def': case 'html': node = null; break
|
|
69
|
+
case 'heading': node = createElement(`h${(token as Tokens.Heading).depth}`, { className: css[`heading${(token as Tokens.Heading).depth}`] }, children); break
|
|
70
|
+
case 'paragraph': node = <p className={css.paragraph}>{children}</p>; break
|
|
71
|
+
case 'strong': node = <strong>{children}</strong>; break
|
|
72
|
+
case 'em': node = <em>{children}</em>; break
|
|
73
|
+
case 'del': node = <del>{children}</del>; break
|
|
74
|
+
case 'codespan': node = <code className={css.inlineCode}>{token.text}</code>; break
|
|
75
|
+
case 'code': node = <pre className={css.codeBlock}><code>{token.text}</code></pre>; break
|
|
76
|
+
case 'br': node = <br />; break
|
|
77
|
+
case 'hr': node = <hr />; break
|
|
78
|
+
case 'blockquote': node = <blockquote className={css.blockquote}>{children}</blockquote>; break
|
|
79
|
+
case 'link': {
|
|
80
|
+
const href = safeHref((token as Tokens.Link).href)
|
|
81
|
+
node = href ? <a className={css.link} href={href} target="_blank" rel="noopener noreferrer">{children}</a> : children
|
|
82
|
+
break
|
|
83
|
+
}
|
|
84
|
+
case 'image': node = <span>{token.text}</span>; break
|
|
85
|
+
case 'list': {
|
|
86
|
+
const list = token as Tokens.List
|
|
87
|
+
const items = list.items.map((item, i) => <li key={i} className={css.listItem}>{item.task && <input type="checkbox" checked={item.checked === true} disabled readOnly />} {renderTokens(item.tokens)}</li>)
|
|
88
|
+
node = list.ordered ? <ol className={css.list} start={list.start || 1}>{items}</ol> : <ul className={css.list}>{items}</ul>
|
|
89
|
+
break
|
|
90
|
+
}
|
|
91
|
+
case 'table': {
|
|
92
|
+
const table = token as Tokens.Table
|
|
93
|
+
node = <div style={{ overflowX: 'auto' }}><table className={css.table}><thead><tr>{table.header.map((cell, i) => <th key={i}>{renderTokens(cell.tokens)}</th>)}</tr></thead><tbody>{table.rows.map((row, i) => <tr key={i}>{row.map((cell, j) => <td key={j}>{renderTokens(cell.tokens)}</td>)}</tr>)}</tbody></table></div>
|
|
94
|
+
break
|
|
95
|
+
}
|
|
96
|
+
default: node = children
|
|
97
|
+
}
|
|
98
|
+
return <Fragment key={index}>{node}</Fragment>
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Markdown rendered as React nodes: raw HTML and automatic image loads are disabled. */
|
|
103
|
+
export function TaskMarkdown({ source }: { source: string }) {
|
|
104
|
+
const content = useMemo(() => renderTokens(Lexer.lex(source, { gfm: true })), [source])
|
|
105
|
+
return <div className={css.markdownRoot}>{content}</div>
|
|
106
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local styles of the task-board settings card.
|
|
3
|
+
*
|
|
4
|
+
* The card's chrome belongs to the shared family slice
|
|
5
|
+
* (`settings-card.module.css`, generated); this file owns only the nested
|
|
6
|
+
* disclosure list and the acceptance block's quiet notes, on theme tokens
|
|
7
|
+
* instead of inline literals.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
.note {
|
|
11
|
+
margin: 4px 0;
|
|
12
|
+
font-size: 12px;
|
|
13
|
+
color: var(--dsw-alias-label-secondary);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.error {
|
|
17
|
+
margin: 4px 0;
|
|
18
|
+
font-size: 12px;
|
|
19
|
+
color: var(--dsw-alias-state-error-primary);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.resolvedList {
|
|
23
|
+
margin: 4px 0 8px;
|
|
24
|
+
padding-left: 16px;
|
|
25
|
+
font-size: 12px;
|
|
26
|
+
color: var(--dsw-alias-label-secondary);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* Live power facts plus the battery caveat, below the topic cards: quiet
|
|
30
|
+
small print that follows the disclosures instead of competing with them.
|
|
31
|
+
No rule of its own — the last topic card already closes with its border. */
|
|
32
|
+
.power {
|
|
33
|
+
display: flex;
|
|
34
|
+
flex-direction: column;
|
|
35
|
+
gap: 2px;
|
|
36
|
+
margin-top: 14px;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* Both the live power line and the battery caveat: secondary ink at the small
|
|
40
|
+
size the 12-13px roles use, so neither competes with the topic cards. */
|
|
41
|
+
.powerLine {
|
|
42
|
+
margin: 0;
|
|
43
|
+
font-size: 12px;
|
|
44
|
+
line-height: 1.5;
|
|
45
|
+
color: var(--dsw-alias-label-secondary);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.powerError {
|
|
49
|
+
margin: 0;
|
|
50
|
+
font-size: 12px;
|
|
51
|
+
line-height: 1.5;
|
|
52
|
+
color: var(--dsw-alias-state-error-primary);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* The nested disclosure cards of this card: the board and its runtime
|
|
56
|
+
behavior, goal acceptance, and the sections a provider extension
|
|
57
|
+
contributes. The gap keeps the stacked card
|
|
58
|
+
borders apart; an empty list draws nothing. */
|
|
59
|
+
.nestedCards {
|
|
60
|
+
display: flex;
|
|
61
|
+
flex-direction: column;
|
|
62
|
+
gap: 8px;
|
|
63
|
+
list-style: none;
|
|
64
|
+
margin: 0;
|
|
65
|
+
padding: 0;
|
|
66
|
+
}
|