dsh-taskboard 0.1.0
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/LICENSE +201 -0
- package/README.md +169 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +2085 -0
- package/lib/host/execution.js +189 -0
- package/lib/host/execution.js.map +1 -0
- package/lib/host/protocol-text.js +37 -0
- package/lib/host/protocol-text.js.map +1 -0
- package/lib/host/routes.js +369 -0
- package/lib/host/routes.js.map +1 -0
- package/lib/host/scheduler.js +91 -0
- package/lib/host/scheduler.js.map +1 -0
- package/lib/host/sdk.js +145 -0
- package/lib/host/sdk.js.map +1 -0
- package/lib/host/store.js +112 -0
- package/lib/host/store.js.map +1 -0
- package/lib/host/tools.js +620 -0
- package/lib/host/tools.js.map +1 -0
- package/lib/index.js +91 -0
- package/lib/index.js.map +1 -0
- package/lib/invariant.js +22 -0
- package/lib/invariant.js.map +1 -0
- package/lib/shared/api.js +9 -0
- package/lib/shared/api.js.map +1 -0
- package/lib/shared/protocol.js +279 -0
- package/lib/shared/protocol.js.map +1 -0
- package/package.json +74 -0
- package/src/client/api.ts +90 -0
- package/src/client/board/NewTaskModal.tsx +8 -0
- package/src/client/board/TaskBoard.tsx +184 -0
- package/src/client/board/TaskCard.tsx +61 -0
- package/src/client/board/TaskDetail.tsx +210 -0
- package/src/client/board/TaskFormModal.tsx +257 -0
- package/src/client/board-mount.tsx +92 -0
- package/src/client/controller.ts +241 -0
- package/src/client/index.ts +87 -0
- package/src/client/sidebar-entry.ts +165 -0
- package/src/client/styles.ts +391 -0
- package/src/host/execution.ts +244 -0
- package/src/host/protocol-text.ts +37 -0
- package/src/host/routes.ts +387 -0
- package/src/host/scheduler.ts +107 -0
- package/src/host/sdk.ts +200 -0
- package/src/host/store.ts +139 -0
- package/src/host/tools.ts +631 -0
- package/src/index.ts +124 -0
- package/src/invariant.ts +22 -0
- package/src/shared/api.ts +98 -0
- package/src/shared/protocol.ts +475 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The task form modal — create and edit in one polished dialog: header with
|
|
3
|
+
* icon / subtitle / close, a sectioned field grid (title, project, model,
|
|
4
|
+
* urgency tri-picker with hints, description, prompt, execution-mode
|
|
5
|
+
* segmented picker, cron with presets and a live next-run preview), and a
|
|
6
|
+
* footer bar carrying the validation hint and the actions. Esc closes;
|
|
7
|
+
* the title input is focused on open.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-taskboard/client/board/TaskFormModal
|
|
10
|
+
*/
|
|
11
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
12
|
+
import type { BoardController } from '../controller.ts'
|
|
13
|
+
import type { Urgency } from '../../shared/protocol.ts'
|
|
14
|
+
import { nextCronTime, parseCron } from '../../shared/protocol.ts'
|
|
15
|
+
import { fmtTime } from './TaskBoard.tsx'
|
|
16
|
+
|
|
17
|
+
/** One row of the configured model catalog (from llm.models). */
|
|
18
|
+
export interface CatalogModel { provider: string; model: string; name?: string }
|
|
19
|
+
|
|
20
|
+
/** Urgency segmented options with a one-line hint each. */
|
|
21
|
+
const URGENCY_OPTIONS: ReadonlyArray<{ value: Urgency; label: string; hint: string }> = [
|
|
22
|
+
{ value: 'urgent', label: '紧急', hint: '优先处理' },
|
|
23
|
+
{ value: 'normal', label: '一般', hint: '正常排期' },
|
|
24
|
+
{ value: 'relaxed', label: '不急', hint: '有空再做' },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
/** Cron presets offered in the scheduled mode. */
|
|
28
|
+
const CRON_PRESETS: ReadonlyArray<{ label: string; cron: string }> = [
|
|
29
|
+
{ label: '每天 09:00', cron: '0 9 * * *' },
|
|
30
|
+
{ label: '每小时', cron: '0 * * * *' },
|
|
31
|
+
{ label: '每 10 分钟', cron: '*/10 * * * *' },
|
|
32
|
+
{ label: '每周一 09:00', cron: '0 9 * * 1' },
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
/** Field shell: label + control, optionally spanning the full grid row. */
|
|
36
|
+
function Field({ label, required = false, full = false, children }: {
|
|
37
|
+
label: string
|
|
38
|
+
required?: boolean
|
|
39
|
+
full?: boolean
|
|
40
|
+
children: ReactNode
|
|
41
|
+
}) {
|
|
42
|
+
return (
|
|
43
|
+
<label className="dsh-atb-field" data-span={full ? 'full' : undefined}>
|
|
44
|
+
<span className="dsh-atb-field-label">
|
|
45
|
+
{label}
|
|
46
|
+
{required && <em className="dsh-atb-req">*</em>}
|
|
47
|
+
</span>
|
|
48
|
+
{children}
|
|
49
|
+
</label>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The form modal. Without `task` it composes a new task; with `task` it
|
|
55
|
+
* edits that record (project, urgency, execution, model included — the GUI
|
|
56
|
+
* is the owner surface).
|
|
57
|
+
* @param controller - the controller.
|
|
58
|
+
* @param task - the task being edited (create mode when absent).
|
|
59
|
+
*/
|
|
60
|
+
export function TaskFormModal({ controller, task }: { controller: BoardController; task?: TaskRecordLike }) {
|
|
61
|
+
const state = controller.getSnapshot()
|
|
62
|
+
const editing = task !== undefined
|
|
63
|
+
const [title, setTitle] = useState(task?.title ?? '')
|
|
64
|
+
const [description, setDescription] = useState(task?.description ?? '')
|
|
65
|
+
const [prompt, setPrompt] = useState(task?.prompt ?? '')
|
|
66
|
+
const [workspaceId, setWorkspaceId] = useState(task?.workspaceId ?? state.filters.workspaceId ?? state.workspaces[0]?.id ?? '')
|
|
67
|
+
const [urgency, setUrgency] = useState<Urgency>(task?.urgency ?? 'normal')
|
|
68
|
+
const [mode, setMode] = useState<'claim' | 'scheduled'>(task?.execution.mode === 'scheduled' ? 'scheduled' : 'claim')
|
|
69
|
+
const [cron, setCron] = useState(task?.execution.cron ?? '0 9 * * *')
|
|
70
|
+
const [catalog, setCatalog] = useState<CatalogModel[]>([])
|
|
71
|
+
const [model, setModel] = useState(task?.model !== undefined ? JSON.stringify(task.model) : '')
|
|
72
|
+
const titleRef = useRef<HTMLInputElement>(null)
|
|
73
|
+
|
|
74
|
+
// Focus the title and close on Esc while the dialog is open.
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
titleRef.current?.focus()
|
|
77
|
+
const onKey = (e: KeyboardEvent): void => {
|
|
78
|
+
if (e.key === 'Escape') controller.closeForm()
|
|
79
|
+
}
|
|
80
|
+
document.addEventListener('keydown', onKey)
|
|
81
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
82
|
+
}, [controller])
|
|
83
|
+
|
|
84
|
+
// Model catalog: the plugin face provides it when the runtime is up.
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
const face = (controller as unknown as { modelCatalog?: () => Promise<CatalogModel[]> }).modelCatalog
|
|
87
|
+
if (face === undefined) return
|
|
88
|
+
void face().then(setCatalog).catch(() => setCatalog([]))
|
|
89
|
+
}, [controller])
|
|
90
|
+
|
|
91
|
+
// Live cron validation + next-run preview (same math as the host).
|
|
92
|
+
const cronMatch = mode === 'scheduled' ? parseCron(cron.trim()) : null
|
|
93
|
+
const nextRun = cronMatch !== null ? nextCronTime(cronMatch, Date.now()) : null
|
|
94
|
+
const cronBad = mode === 'scheduled' && (cronMatch === null || nextRun === null)
|
|
95
|
+
const valid = title.trim().length > 0 && workspaceId !== '' && !cronBad
|
|
96
|
+
|
|
97
|
+
const submit = (): void => {
|
|
98
|
+
if (!valid) return
|
|
99
|
+
const picked = model !== '' ? (JSON.parse(model) as { provider: string; model: string }) : undefined
|
|
100
|
+
if (editing) {
|
|
101
|
+
void controller.update(task.id, task.version, {
|
|
102
|
+
title,
|
|
103
|
+
description,
|
|
104
|
+
prompt,
|
|
105
|
+
urgency,
|
|
106
|
+
workspaceId,
|
|
107
|
+
execution: mode === 'scheduled' ? { mode, cron: cron.trim() } : { mode },
|
|
108
|
+
// '' in edit mode clears the pinned model back to the default.
|
|
109
|
+
model: picked ?? null,
|
|
110
|
+
})
|
|
111
|
+
} else {
|
|
112
|
+
void controller.create({
|
|
113
|
+
title,
|
|
114
|
+
workspaceId,
|
|
115
|
+
urgency,
|
|
116
|
+
description: description.length > 0 ? description : undefined,
|
|
117
|
+
prompt: prompt.length > 0 ? prompt : undefined,
|
|
118
|
+
execution: mode === 'scheduled' ? { mode, cron: cron.trim() } : { mode },
|
|
119
|
+
model: picked,
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const hint = !valid
|
|
125
|
+
? (title.trim().length === 0 ? '请填写标题' : workspaceId === '' ? '请选择项目' : 'Cron 表达式无效(分 时 日 月 周)')
|
|
126
|
+
: mode === 'scheduled' && nextRun !== null
|
|
127
|
+
? `下次运行 ${fmtTime(nextRun)}`
|
|
128
|
+
: editing
|
|
129
|
+
? `保存后版本 v${task.version} → v${task.version + 1}`
|
|
130
|
+
: '创建后项目内会话可认领执行'
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div className="dsh-atb-modal-backdrop" onClick={e => { if (e.target === e.currentTarget) controller.closeForm() }}>
|
|
134
|
+
<div className="dsh-atb-modal" data-mode={editing ? 'edit' : 'create'} role="dialog" aria-modal="true" aria-label={editing ? '编辑任务' : '新建任务'}>
|
|
135
|
+
<div className="dsh-atb-modal-head">
|
|
136
|
+
<span className="dsh-atb-modal-headicon">{editing ? '✎' : '✚'}</span>
|
|
137
|
+
<div className="dsh-atb-modal-headtext">
|
|
138
|
+
<h3>{editing ? '编辑任务' : '新建任务'}</h3>
|
|
139
|
+
<p>{editing ? '调整任务内容与执行配置' : '推入看板,项目内会话可认领执行'}</p>
|
|
140
|
+
</div>
|
|
141
|
+
<button type="button" className="dsh-atb-modal-close" aria-label="关闭" onClick={() => controller.closeForm()}>✕</button>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<div className="dsh-atb-modal-body">
|
|
145
|
+
<Field label="标题" required full>
|
|
146
|
+
<input ref={titleRef} value={title} onChange={e => setTitle(e.target.value)} placeholder="一句话说清要做什么" maxLength={200} />
|
|
147
|
+
</Field>
|
|
148
|
+
|
|
149
|
+
<Field label="项目" required>
|
|
150
|
+
<select value={workspaceId} onChange={e => setWorkspaceId(e.target.value)}>
|
|
151
|
+
{state.workspaces.map(ws => <option key={ws.id} value={ws.id}>{ws.title || ws.path}</option>)}
|
|
152
|
+
</select>
|
|
153
|
+
</Field>
|
|
154
|
+
|
|
155
|
+
<Field label="模型(默认 = 会话默认模型)">
|
|
156
|
+
<select value={model} onChange={e => setModel(e.target.value)}>
|
|
157
|
+
<option value="">默认模型</option>
|
|
158
|
+
{catalog.map(m => (
|
|
159
|
+
<option key={`${m.provider}/${m.model}`} value={JSON.stringify({ provider: m.provider, model: m.model })}>
|
|
160
|
+
{m.name ?? m.model}({m.provider})
|
|
161
|
+
</option>
|
|
162
|
+
))}
|
|
163
|
+
</select>
|
|
164
|
+
</Field>
|
|
165
|
+
|
|
166
|
+
<Field label="紧急度" full>
|
|
167
|
+
<div className="dsh-atb-urgency-picker">
|
|
168
|
+
{URGENCY_OPTIONS.map(o => (
|
|
169
|
+
<button
|
|
170
|
+
key={o.value}
|
|
171
|
+
type="button"
|
|
172
|
+
className="dsh-atb-urgency-opt"
|
|
173
|
+
data-urgency={o.value}
|
|
174
|
+
data-on={urgency === o.value}
|
|
175
|
+
onClick={() => setUrgency(o.value)}
|
|
176
|
+
>
|
|
177
|
+
<span className="dsh-atb-urgency-name"><span className="dsh-atb-dot" data-urgency={o.value} />{o.label}</span>
|
|
178
|
+
<span className="dsh-atb-urgency-hint">{o.hint}</span>
|
|
179
|
+
</button>
|
|
180
|
+
))}
|
|
181
|
+
</div>
|
|
182
|
+
</Field>
|
|
183
|
+
|
|
184
|
+
<Field label={editing ? '描述' : '描述(可选)'} full>
|
|
185
|
+
<textarea value={description} onChange={e => setDescription(e.target.value)} placeholder="需求细节、验收标准…" />
|
|
186
|
+
</Field>
|
|
187
|
+
|
|
188
|
+
<Field label={editing ? '执行 Prompt' : '执行 Prompt(可选,默认 = 标题+描述)'} full>
|
|
189
|
+
<textarea value={prompt} onChange={e => setPrompt(e.target.value)} placeholder="发给执行会话的完整指令" />
|
|
190
|
+
</Field>
|
|
191
|
+
|
|
192
|
+
<Field label="执行方式" full>
|
|
193
|
+
<div className="dsh-atb-mode-picker">
|
|
194
|
+
<button type="button" className="dsh-atb-mode-opt" data-on={mode === 'claim'} onClick={() => setMode('claim')}>
|
|
195
|
+
<span className="dsh-atb-mode-name">🤝 认领制</span>
|
|
196
|
+
<span className="dsh-atb-mode-hint">项目内会话认领</span>
|
|
197
|
+
</button>
|
|
198
|
+
<button type="button" className="dsh-atb-mode-opt" data-on={mode === 'scheduled'} onClick={() => setMode('scheduled')}>
|
|
199
|
+
<span className="dsh-atb-mode-name">⏰ 定时执行</span>
|
|
200
|
+
<span className="dsh-atb-mode-hint">到点自动开跑</span>
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
</Field>
|
|
204
|
+
|
|
205
|
+
{mode === 'scheduled' && (
|
|
206
|
+
<Field label="Cron 表达式" required full>
|
|
207
|
+
<input
|
|
208
|
+
className={cronBad ? 'dsh-atb-input-bad' : undefined}
|
|
209
|
+
value={cron}
|
|
210
|
+
onChange={e => setCron(e.target.value)}
|
|
211
|
+
placeholder="分 时 日 月 周"
|
|
212
|
+
spellCheck={false}
|
|
213
|
+
/>
|
|
214
|
+
<span className="dsh-atb-cron-presets">
|
|
215
|
+
{CRON_PRESETS.map(p => (
|
|
216
|
+
<button
|
|
217
|
+
key={p.cron}
|
|
218
|
+
type="button"
|
|
219
|
+
className="dsh-atb-cron-preset"
|
|
220
|
+
data-on={cron.trim() === p.cron}
|
|
221
|
+
onClick={() => setCron(p.cron)}
|
|
222
|
+
>
|
|
223
|
+
{p.label}
|
|
224
|
+
</button>
|
|
225
|
+
))}
|
|
226
|
+
{!cronBad && nextRun !== null && <span className="dsh-atb-cron-next">下次 {fmtTime(nextRun)}</span>}
|
|
227
|
+
</span>
|
|
228
|
+
</Field>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<div className="dsh-atb-modal-foot">
|
|
233
|
+
<span className="dsh-atb-modal-hint" data-tone={valid ? undefined : 'bad'}>{hint}</span>
|
|
234
|
+
<span className="dsh-atb-modal-footbtns">
|
|
235
|
+
<button type="button" className="dsh-atb-btn" onClick={() => controller.closeForm()}>取消</button>
|
|
236
|
+
<button type="button" className="dsh-atb-btn" data-primary="true" disabled={!valid} onClick={submit}>
|
|
237
|
+
{editing ? '保存修改' : '创建任务'}
|
|
238
|
+
</button>
|
|
239
|
+
</span>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The record shape this form edits (narrow structural type to avoid a value import). */
|
|
247
|
+
interface TaskRecordLike {
|
|
248
|
+
id: string
|
|
249
|
+
version: number
|
|
250
|
+
title: string
|
|
251
|
+
description: string
|
|
252
|
+
prompt: string
|
|
253
|
+
workspaceId: string
|
|
254
|
+
urgency: Urgency
|
|
255
|
+
execution: { mode: 'claim' | 'scheduled'; cron?: string }
|
|
256
|
+
model?: { provider: string; model: string }
|
|
257
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Board view mounting: a container appended inside the `[data-pane=
|
|
3
|
+
* "conversation"]` grid item (a trailing child React never manages), with a
|
|
4
|
+
* stylesheet rule hiding the conversation content while the board is active.
|
|
5
|
+
* Toggling rides a data attribute on <html> — no React involvement in the
|
|
6
|
+
* shell.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-taskboard/client/board-mount
|
|
9
|
+
*/
|
|
10
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
11
|
+
import type { BoardController } from './controller.ts'
|
|
12
|
+
import { TaskBoard } from './board/TaskBoard.tsx'
|
|
13
|
+
|
|
14
|
+
/** The injected board container. */
|
|
15
|
+
export const BOARD_VIEW_SELECTOR = '[data-dsh-atb-view]'
|
|
16
|
+
|
|
17
|
+
const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"]'
|
|
18
|
+
const ACTIVE_ATTR = 'data-dsh-atb-active'
|
|
19
|
+
/** Sibling panels' activation attributes, evicted when this board opens. */
|
|
20
|
+
const OTHER_ACTIVE_ATTRS = ['data-dsh-taskboard-active', 'data-dsh-ssh-active']
|
|
21
|
+
/** Cross-plugin activation event; detail is the activating panel name. */
|
|
22
|
+
const ACTIVATE_EVENT = 'dsh-panel-activate'
|
|
23
|
+
// 'taskboard' is the family ui-task-board panel's event name; stay distinct to keep eviction working.
|
|
24
|
+
const PANEL_NAME = 'dsh-taskboard'
|
|
25
|
+
|
|
26
|
+
/** Find the center column. */
|
|
27
|
+
function conversationColumn(): HTMLElement | undefined {
|
|
28
|
+
return document.querySelector<HTMLElement>(CONVERSATION_COLUMN_SELECTOR) ?? undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Mount the board React tree and bind visibility to the controller.
|
|
33
|
+
* @param controller - the controller.
|
|
34
|
+
* @returns disposer.
|
|
35
|
+
*/
|
|
36
|
+
export function mountBoard(controller: BoardController): () => void {
|
|
37
|
+
let root: Root | undefined
|
|
38
|
+
let container: HTMLDivElement | undefined
|
|
39
|
+
|
|
40
|
+
const ensure = (): void => {
|
|
41
|
+
if (container !== undefined) return
|
|
42
|
+
const column = conversationColumn()
|
|
43
|
+
if (column === undefined) return
|
|
44
|
+
container = document.createElement('div')
|
|
45
|
+
container.dataset.dshAtbView = ''
|
|
46
|
+
container.className = 'dsh-atb-view'
|
|
47
|
+
column.appendChild(container)
|
|
48
|
+
root = createRoot(container)
|
|
49
|
+
root.render(<TaskBoard controller={controller} />)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const waitObserver = new MutationObserver(() => { ensure() })
|
|
53
|
+
waitObserver.observe(document.body, { childList: true, subtree: true })
|
|
54
|
+
|
|
55
|
+
const applyActive = (): void => {
|
|
56
|
+
if (controller.getSnapshot().boardOpen) {
|
|
57
|
+
for (const attr of OTHER_ACTIVE_ATTRS) document.documentElement.removeAttribute(attr)
|
|
58
|
+
document.documentElement.setAttribute(ACTIVE_ATTR, '')
|
|
59
|
+
document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }))
|
|
60
|
+
} else {
|
|
61
|
+
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const onOtherActivate = (event: Event): void => {
|
|
65
|
+
const detail = (event as CustomEvent).detail
|
|
66
|
+
if (detail !== PANEL_NAME && controller.getSnapshot().boardOpen) {
|
|
67
|
+
controller.closeBoard()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
|
|
71
|
+
const onClickSidebarRow = (event: MouseEvent): void => {
|
|
72
|
+
if (!controller.getSnapshot().boardOpen) return
|
|
73
|
+
const target = event.target as HTMLElement | null
|
|
74
|
+
if (target === null) return
|
|
75
|
+
if (target.closest(SIDEBAR_ROW_SELECTOR) !== null) controller.closeBoard()
|
|
76
|
+
}
|
|
77
|
+
document.addEventListener('click', onClickSidebarRow, true)
|
|
78
|
+
document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
|
|
79
|
+
const unsubscribe = controller.subscribe(applyActive)
|
|
80
|
+
applyActive()
|
|
81
|
+
ensure()
|
|
82
|
+
|
|
83
|
+
return () => {
|
|
84
|
+
document.removeEventListener('click', onClickSidebarRow, true)
|
|
85
|
+
document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
|
|
86
|
+
waitObserver.disconnect()
|
|
87
|
+
unsubscribe()
|
|
88
|
+
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
|
89
|
+
root?.unmount()
|
|
90
|
+
container?.remove()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The board controller: framework-free state holder the React views render
|
|
3
|
+
* from. Owns the ledger snapshot, workspace listing, view state (open,
|
|
4
|
+
* filters, selection, modals), and the SSE subscription with gap-triggered
|
|
5
|
+
* full refetch. Every mutation goes through the route client and lands in the
|
|
6
|
+
* snapshot through the SSE change stream or the explicit refetch.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-taskboard/client/controller
|
|
9
|
+
*/
|
|
10
|
+
import type { ChangeEvent, UpdateTaskBody, WorkspaceView } from '../shared/api.ts'
|
|
11
|
+
import type { TaskLedger, TaskRecord, Urgency } from '../shared/protocol.ts'
|
|
12
|
+
import { emptyLedger } from '../shared/protocol.ts'
|
|
13
|
+
import type { TaskboardClient } from './api.ts'
|
|
14
|
+
|
|
15
|
+
/** View filters over the ledger. */
|
|
16
|
+
export interface BoardFilters {
|
|
17
|
+
/** Selected project id; undefined = all projects. */
|
|
18
|
+
workspaceId?: string
|
|
19
|
+
/** Selected urgency chips (empty = all). */
|
|
20
|
+
urgencies: Urgency[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Controller snapshot the views render. */
|
|
24
|
+
export interface ControllerState {
|
|
25
|
+
boardOpen: boolean
|
|
26
|
+
ledger: TaskLedger
|
|
27
|
+
workspaces: WorkspaceView[]
|
|
28
|
+
filters: BoardFilters
|
|
29
|
+
/** Selected task id (detail view); undefined closes the detail. */
|
|
30
|
+
selectedId?: string
|
|
31
|
+
/** Task form modal visible (create when editingId is unset). */
|
|
32
|
+
composerOpen: boolean
|
|
33
|
+
/** Task being edited in the form modal; unset = create mode. */
|
|
34
|
+
editingId?: string
|
|
35
|
+
/** Secondary (canceled/archived/trashed) tab visible. */
|
|
36
|
+
secondaryOpen: boolean
|
|
37
|
+
/** Transient error surface (action failures); cleared on next success. */
|
|
38
|
+
error?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Instantiate the default state. */
|
|
42
|
+
function initialState(): ControllerState {
|
|
43
|
+
return {
|
|
44
|
+
boardOpen: false,
|
|
45
|
+
ledger: emptyLedger(),
|
|
46
|
+
workspaces: [],
|
|
47
|
+
filters: { urgencies: [] },
|
|
48
|
+
composerOpen: false,
|
|
49
|
+
secondaryOpen: false,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The board controller.
|
|
55
|
+
*/
|
|
56
|
+
export class BoardController {
|
|
57
|
+
private state: ControllerState = initialState()
|
|
58
|
+
private readonly subscribers = new Set<() => void>()
|
|
59
|
+
private disposed = false
|
|
60
|
+
private disposeStream: (() => void) | undefined
|
|
61
|
+
private refreshInFlight: Promise<void> | undefined
|
|
62
|
+
|
|
63
|
+
/** @param client - the route client. */
|
|
64
|
+
constructor(private readonly client: TaskboardClient) {}
|
|
65
|
+
|
|
66
|
+
/** Current snapshot (render input). */
|
|
67
|
+
getSnapshot(): ControllerState {
|
|
68
|
+
return this.state
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Subscribe; returns unsubscribe. */
|
|
72
|
+
subscribe(fn: () => void): () => void {
|
|
73
|
+
this.subscribers.add(fn)
|
|
74
|
+
return () => this.subscribers.delete(fn)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private emit(): void {
|
|
78
|
+
if (this.disposed) return
|
|
79
|
+
for (const fn of this.subscribers) fn()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private setState(patch: Partial<ControllerState>): void {
|
|
83
|
+
this.state = { ...this.state, ...patch }
|
|
84
|
+
this.emit()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Start subscriptions; call once after construction. */
|
|
88
|
+
start(): void {
|
|
89
|
+
void this.refresh()
|
|
90
|
+
this.disposeStream = this.client.stream(
|
|
91
|
+
(change: ChangeEvent) => {
|
|
92
|
+
this.setState({ ledger: { ...this.state.ledger, revision: change.revision } })
|
|
93
|
+
// Any change invalidates the full snapshot; refetch (cheap, local).
|
|
94
|
+
void this.refresh()
|
|
95
|
+
},
|
|
96
|
+
() => { void this.refresh() },
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Full refetch (state + workspaces + open detail). */
|
|
101
|
+
async refresh(): Promise<void> {
|
|
102
|
+
if (this.refreshInFlight !== undefined) return this.refreshInFlight
|
|
103
|
+
this.refreshInFlight = (async () => {
|
|
104
|
+
try {
|
|
105
|
+
const [ledger, workspaces] = await Promise.all([
|
|
106
|
+
this.client.state(),
|
|
107
|
+
this.client.workspaces(),
|
|
108
|
+
])
|
|
109
|
+
let selected: TaskRecord | undefined
|
|
110
|
+
if (this.state.selectedId !== undefined) {
|
|
111
|
+
selected = ledger.tasks.find(t => t.id === this.state.selectedId)
|
|
112
|
+
}
|
|
113
|
+
this.setState({ ledger, workspaces, error: undefined, selectedId: selected === undefined ? undefined : this.state.selectedId })
|
|
114
|
+
} catch (error) {
|
|
115
|
+
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
116
|
+
} finally {
|
|
117
|
+
this.refreshInFlight = undefined
|
|
118
|
+
}
|
|
119
|
+
})()
|
|
120
|
+
return this.refreshInFlight
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Stop everything. */
|
|
124
|
+
dispose(): void {
|
|
125
|
+
this.disposed = true
|
|
126
|
+
this.disposeStream?.()
|
|
127
|
+
this.subscribers.clear()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ------------------------------------------------------------------ view
|
|
131
|
+
/** Open the board (sidebar entry). */
|
|
132
|
+
openBoard(): void { this.setState({ boardOpen: true }) }
|
|
133
|
+
|
|
134
|
+
/** Close the board. */
|
|
135
|
+
closeBoard(): void { this.setState({ boardOpen: false }) }
|
|
136
|
+
|
|
137
|
+
/** Toggle the board. */
|
|
138
|
+
toggleBoard(): void { this.setState({ boardOpen: !this.state.boardOpen }) }
|
|
139
|
+
|
|
140
|
+
/** Set the project filter. */
|
|
141
|
+
setWorkspaceFilter(workspaceId?: string): void {
|
|
142
|
+
this.setState({ filters: { ...this.state.filters, workspaceId } })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Toggle one urgency chip. */
|
|
146
|
+
toggleUrgency(urgency: Urgency): void {
|
|
147
|
+
const set = new Set(this.state.filters.urgencies)
|
|
148
|
+
if (set.has(urgency)) set.delete(urgency)
|
|
149
|
+
else set.add(urgency)
|
|
150
|
+
this.setState({ filters: { ...this.state.filters, urgencies: [...set] } })
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Select a task (open detail). */
|
|
154
|
+
select(id?: string): void { this.setState({ selectedId: id }) }
|
|
155
|
+
|
|
156
|
+
/** Show/hide the task form (create mode when opening). */
|
|
157
|
+
setComposer(open: boolean): void { this.setState({ composerOpen: open, editingId: undefined }) }
|
|
158
|
+
|
|
159
|
+
/** Open the form modal editing an existing task. */
|
|
160
|
+
openEditor(id: string): void { this.setState({ composerOpen: true, editingId: id }) }
|
|
161
|
+
|
|
162
|
+
/** Close the form modal whatever its mode. */
|
|
163
|
+
closeForm(): void { this.setState({ composerOpen: false, editingId: undefined }) }
|
|
164
|
+
|
|
165
|
+
/** Toggle the secondary tab. */
|
|
166
|
+
toggleSecondary(): void { this.setState({ secondaryOpen: !this.state.secondaryOpen }) }
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------- writes
|
|
169
|
+
/** Create a task (composer submit). */
|
|
170
|
+
async create(body: Parameters<TaskboardClient['create']>[0]): Promise<void> {
|
|
171
|
+
try {
|
|
172
|
+
await this.client.create(body)
|
|
173
|
+
this.setState({ composerOpen: false, error: undefined })
|
|
174
|
+
await this.refresh()
|
|
175
|
+
} catch (error) {
|
|
176
|
+
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Edit task fields (form modal submit; the GUI is the owner surface). */
|
|
181
|
+
async update(id: string, ifVersion: number, body: Omit<UpdateTaskBody, 'ifVersion'>): Promise<void> {
|
|
182
|
+
try {
|
|
183
|
+
await this.client.update(id, { ifVersion, ...body })
|
|
184
|
+
this.setState({ composerOpen: false, editingId: undefined, error: undefined })
|
|
185
|
+
await this.refresh()
|
|
186
|
+
} catch (error) {
|
|
187
|
+
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Move a task (user surface: done allowed). */
|
|
192
|
+
async move(id: string, ifVersion: number, status: string): Promise<void> {
|
|
193
|
+
try {
|
|
194
|
+
await this.client.move(id, { ifVersion, status })
|
|
195
|
+
await this.refresh()
|
|
196
|
+
} catch (error) {
|
|
197
|
+
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Toggle the blocked marker. */
|
|
202
|
+
async toggleBlocked(task: TaskRecord): Promise<void> {
|
|
203
|
+
try {
|
|
204
|
+
await this.client.update(task.id, { ifVersion: task.version, blocked: !task.blocked })
|
|
205
|
+
await this.refresh()
|
|
206
|
+
} catch (error) {
|
|
207
|
+
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Append a user comment. */
|
|
212
|
+
async comment(id: string, body: string): Promise<void> {
|
|
213
|
+
try {
|
|
214
|
+
await this.client.comment(id, body)
|
|
215
|
+
await this.refresh()
|
|
216
|
+
} catch (error) {
|
|
217
|
+
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Trigger a manual run (fresh in-project session, pinned model). */
|
|
222
|
+
async run(id: string): Promise<void> {
|
|
223
|
+
try {
|
|
224
|
+
await this.client.run(id)
|
|
225
|
+
await this.refresh()
|
|
226
|
+
} catch (error) {
|
|
227
|
+
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Soft-delete (agent parity) then optional purge. */
|
|
232
|
+
async remove(id: string, ifVersion: number, purge: boolean): Promise<void> {
|
|
233
|
+
try {
|
|
234
|
+
await this.client.remove(id, purge ? { purge: true } : { ifVersion })
|
|
235
|
+
if (purge) this.setState({ selectedId: undefined })
|
|
236
|
+
await this.refresh()
|
|
237
|
+
} catch (error) {
|
|
238
|
+
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half entry for dsh-taskboard: wires the route client and the
|
|
3
|
+
* board controller, exposes the model catalog (via the runtime's llm.models
|
|
4
|
+
* RPC when the connection service is present), mounts the sidebar entry and
|
|
5
|
+
* the board view.
|
|
6
|
+
*
|
|
7
|
+
* Failure policy: DOM mounting problems are logged, never thrown — the web
|
|
8
|
+
* shell fails the whole boot when a plugin apply throws.
|
|
9
|
+
*
|
|
10
|
+
* Export shape: `name` / `inject` / `apply`, no default.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-taskboard/client
|
|
13
|
+
*/
|
|
14
|
+
import { createClient } from './api.ts'
|
|
15
|
+
import { BoardController } from './controller.ts'
|
|
16
|
+
import { injectStyles } from './styles.ts'
|
|
17
|
+
import { mountSidebarEntry } from './sidebar-entry.ts'
|
|
18
|
+
import { mountBoard } from './board-mount.tsx'
|
|
19
|
+
|
|
20
|
+
/** Client plugin name. */
|
|
21
|
+
export const name = 'dsh-taskboard/client'
|
|
22
|
+
|
|
23
|
+
/** Required client services (fiber inject waiting). */
|
|
24
|
+
export const inject = ['connection']
|
|
25
|
+
|
|
26
|
+
/** Narrow connection face for the model catalog. */
|
|
27
|
+
interface ConnectionFace {
|
|
28
|
+
api: {
|
|
29
|
+
llm: {
|
|
30
|
+
models(payload: Record<string, never>): Promise<{ result: { ok: true; value: { groups: Array<{ id: string; name: string; models: Array<{ id: string; name?: string }> }> } } | { ok: false } }>
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Effect-hook face the runner provides on the client context. */
|
|
36
|
+
interface ClientContextFace {
|
|
37
|
+
get?(name: string): unknown
|
|
38
|
+
effect?(fn: () => unknown, label?: string): void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Mount the client half.
|
|
43
|
+
* @param ctx - the client context (connection injected).
|
|
44
|
+
*/
|
|
45
|
+
export function apply(ctx: ClientContextFace): void {
|
|
46
|
+
try {
|
|
47
|
+
injectStyles()
|
|
48
|
+
const client = createClient()
|
|
49
|
+
const controller = new BoardController(client)
|
|
50
|
+
|
|
51
|
+
// Model catalog for the composer: llm.models over the connection RPC.
|
|
52
|
+
const connection = ctx.get?.('connection') as ConnectionFace | undefined
|
|
53
|
+
if (connection !== undefined) {
|
|
54
|
+
type CatalogRow = { provider: string; model: string; name?: string }
|
|
55
|
+
;(controller as unknown as { modelCatalog?: () => Promise<CatalogRow[]> }).modelCatalog = async (): Promise<CatalogRow[]> => {
|
|
56
|
+
const response = await connection.api.llm.models({})
|
|
57
|
+
if (!response.result.ok) return []
|
|
58
|
+
const out: CatalogRow[] = []
|
|
59
|
+
for (const group of response.result.value.groups) {
|
|
60
|
+
for (const model of group.models) {
|
|
61
|
+
out.push({ provider: group.id, model: model.id, name: model.name })
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
controller.start()
|
|
69
|
+
const disposers: Array<() => void> = []
|
|
70
|
+
try {
|
|
71
|
+
disposers.push(mountSidebarEntry(controller))
|
|
72
|
+
disposers.push(mountBoard(controller))
|
|
73
|
+
} catch (error) {
|
|
74
|
+
// DOM failures degrade the board, never the GUI.
|
|
75
|
+
console.error('[dsh-taskboard] mount failed:', error)
|
|
76
|
+
}
|
|
77
|
+
// cordis effect semantics: the callback runs immediately and its RETURN
|
|
78
|
+
// VALUE is the disposer (family-plugin precedent: () => () => {...}).
|
|
79
|
+
// A single-layer arrow here executes the teardown immediately.
|
|
80
|
+
ctx.effect?.(() => () => {
|
|
81
|
+
for (const d of disposers.splice(0)) d()
|
|
82
|
+
controller.dispose()
|
|
83
|
+
}, 'dsh-taskboard: client mount')
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.error('[dsh-taskboard] client half failed to start:', error)
|
|
86
|
+
}
|
|
87
|
+
}
|