@linxin666/dsh-client-ui-task-board 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +29 -0
- package/README.md +211 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +2592 -0
- package/lib/index.js +54 -0
- package/lib/invariant.js +6 -0
- package/lib/types/client/PluginSettingsCard.d.ts +77 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +51 -0
- package/lib/types/client/board/ConfirmDialog.d.ts +12 -0
- package/lib/types/client/board/NewTaskModal.d.ts +6 -0
- package/lib/types/client/board/TaskBoard.d.ts +5 -0
- package/lib/types/client/board/TaskCard.d.ts +12 -0
- package/lib/types/client/board/TaskDetail.d.ts +7 -0
- package/lib/types/client/board-mount.d.ts +10 -0
- package/lib/types/client/index.d.ts +43 -0
- package/lib/types/client/locales.d.ts +105 -0
- package/lib/types/client/settings-form.d.ts +118 -0
- package/lib/types/client/sidebar-entry.d.ts +25 -0
- package/lib/types/core/controller.d.ts +129 -0
- package/lib/types/core/execution.d.ts +136 -0
- package/lib/types/core/schedule.d.ts +40 -0
- package/lib/types/core/scheduler.d.ts +49 -0
- package/lib/types/core/store.d.ts +36 -0
- package/lib/types/core/tasks.d.ts +111 -0
- package/lib/types/index.d.ts +44 -0
- package/lib/types/invariant.d.ts +3 -0
- package/package.json +85 -0
- package/src/client/PluginSettingsCard.tsx +207 -0
- package/src/client/TaskBoardSettingsCard.tsx +124 -0
- package/src/client/board/ConfirmDialog.tsx +40 -0
- package/src/client/board/NewTaskModal.tsx +82 -0
- package/src/client/board/TaskBoard.tsx +107 -0
- package/src/client/board/TaskCard.tsx +62 -0
- package/src/client/board/TaskDetail.tsx +286 -0
- package/src/client/board-mount.tsx +74 -0
- package/src/client/board.module.css +849 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/index.ts +167 -0
- package/src/client/locales.ts +211 -0
- package/src/client/settings-card.module.css +261 -0
- package/src/client/settings-form.ts +294 -0
- package/src/client/sidebar-entry.ts +118 -0
- package/src/core/controller.ts +379 -0
- package/src/core/execution.ts +259 -0
- package/src/core/schedule.ts +140 -0
- package/src/core/scheduler.ts +104 -0
- package/src/core/store.ts +183 -0
- package/src/core/tasks.ts +204 -0
- package/src/index.ts +94 -0
- package/src/invariant.ts +4 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared chrome for the plugin settings card: a disclosure header naming the
|
|
3
|
+
* plugin and what its settings govern, the controls inside, and the save that
|
|
4
|
+
* writes them. Renders nothing while the namespace is unavailable — a
|
|
5
|
+
* deployment that does not compose the owning plugin should show no trace of
|
|
6
|
+
* it. Mirrors the official ui-plugin-config PluginCard in a self-contained
|
|
7
|
+
* slice (this package must not depend on a sibling UI package).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useState, type ReactNode } from 'react'
|
|
11
|
+
import type { CardShell } from './settings-form.ts'
|
|
12
|
+
import type { SettingsCardKey } from './locales.ts'
|
|
13
|
+
import css from './settings-card.module.css'
|
|
14
|
+
|
|
15
|
+
/** Card chrome shared by every plugin settings card. */
|
|
16
|
+
export interface PluginSettingsCardProps {
|
|
17
|
+
/** Locale reader for this card's copy. */
|
|
18
|
+
t: (key: SettingsCardKey) => string
|
|
19
|
+
/** Locale key of the plugin's name. */
|
|
20
|
+
titleKey: SettingsCardKey
|
|
21
|
+
/** Locale key of the line describing what this plugin's settings govern. */
|
|
22
|
+
descriptionKey: SettingsCardKey
|
|
23
|
+
/** The card's form state: availability, writability, and what a save would do. */
|
|
24
|
+
state: CardShell
|
|
25
|
+
/** Write every staged edit. */
|
|
26
|
+
onSave: () => void
|
|
27
|
+
/** Drop every staged edit. */
|
|
28
|
+
onDiscard: () => void
|
|
29
|
+
/** The plugin's controls. */
|
|
30
|
+
children: ReactNode
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Render one plugin settings card.
|
|
35
|
+
* @param props - the plugin's copy keys, its form state, and its controls.
|
|
36
|
+
* @returns the card, or nothing when the namespace is unavailable.
|
|
37
|
+
*/
|
|
38
|
+
export function PluginSettingsCard(props: PluginSettingsCardProps) {
|
|
39
|
+
const [open, setOpen] = useState(false)
|
|
40
|
+
const { state } = props
|
|
41
|
+
if (!state.available) return null
|
|
42
|
+
const title = props.t(props.titleKey)
|
|
43
|
+
const blocked = !state.dirty || state.invalid || state.saving
|
|
44
|
+
return (
|
|
45
|
+
<li className={css.card}>
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
className={css.header}
|
|
49
|
+
aria-expanded={open}
|
|
50
|
+
aria-label={`${props.t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`}
|
|
51
|
+
onClick={() => { setOpen(!open) }}
|
|
52
|
+
>
|
|
53
|
+
<span className={css.headText}>
|
|
54
|
+
<span className={css.name}>{title}</span>
|
|
55
|
+
<span className={css.description}>{props.t(props.descriptionKey)}</span>
|
|
56
|
+
</span>
|
|
57
|
+
{state.dirty ? <span className={css.pending}>{props.t('settings.unsaved')}</span> : null}
|
|
58
|
+
<span className={open ? css.chevronOpen : css.chevron}>▾</span>
|
|
59
|
+
</button>
|
|
60
|
+
{open
|
|
61
|
+
? (
|
|
62
|
+
<div className={css.body}>
|
|
63
|
+
{!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
|
|
64
|
+
{props.children}
|
|
65
|
+
<div className={css.footer}>
|
|
66
|
+
{state.failed ? <p className={css.failed} role="status">{props.t('settings.saveFailed')}</p> : null}
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
className={css.discard}
|
|
70
|
+
disabled={!state.dirty || state.saving}
|
|
71
|
+
onClick={props.onDiscard}
|
|
72
|
+
>
|
|
73
|
+
{props.t('settings.discard')}
|
|
74
|
+
</button>
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
className={css.save}
|
|
78
|
+
disabled={blocked}
|
|
79
|
+
onClick={props.onSave}
|
|
80
|
+
>
|
|
81
|
+
{props.t(!state.saving ? 'settings.save' : 'settings.saving')}
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
: null}
|
|
87
|
+
</li>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Props every field control needs regardless of its value type. */
|
|
92
|
+
export interface FieldProps {
|
|
93
|
+
/** Stable id associating the label with its control. */
|
|
94
|
+
id: string
|
|
95
|
+
/** Visible label. */
|
|
96
|
+
label: string
|
|
97
|
+
/** One-line explanation rendered under the control. */
|
|
98
|
+
hint: string
|
|
99
|
+
/** Draft text this control renders. */
|
|
100
|
+
text: string
|
|
101
|
+
/** True when saving would leave a user-layer entry for this field. */
|
|
102
|
+
overridden: boolean
|
|
103
|
+
/** True when the draft is not a value this field accepts. */
|
|
104
|
+
invalid: boolean
|
|
105
|
+
/** Copy for the overridden badge. */
|
|
106
|
+
overriddenLabel: string
|
|
107
|
+
/** Copy for the reset control. */
|
|
108
|
+
resetLabel: string
|
|
109
|
+
/** Copy shown in place of the hint while the draft is invalid. */
|
|
110
|
+
invalidLabel: string
|
|
111
|
+
/** Disables every control (read-only document, or an unavailable namespace). */
|
|
112
|
+
disabled: boolean
|
|
113
|
+
/** Stage draft text. */
|
|
114
|
+
onEdit: (text: string) => void
|
|
115
|
+
/** Stage a clear so the field re-inherits the composition layer. */
|
|
116
|
+
onReset: () => void
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A staged value field. `numeric` only hints the keypad: which drafts a field accepts is decided by its spec. */
|
|
120
|
+
export function ValueField(props: FieldProps & {
|
|
121
|
+
/** Hints a numeric keypad without narrowing what the control accepts. */
|
|
122
|
+
numeric?: boolean
|
|
123
|
+
/** Placeholder shown while the draft is empty. */
|
|
124
|
+
placeholder?: string
|
|
125
|
+
}) {
|
|
126
|
+
return (
|
|
127
|
+
<div className={css.field}>
|
|
128
|
+
<div className={css.head}>
|
|
129
|
+
<label className={css.label} htmlFor={props.id}>{props.label}</label>
|
|
130
|
+
{props.overridden
|
|
131
|
+
? (
|
|
132
|
+
<span className={css.badges}>
|
|
133
|
+
<span className={css.badge}>{props.overriddenLabel}</span>
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
className={css.reset}
|
|
137
|
+
disabled={props.disabled}
|
|
138
|
+
onClick={props.onReset}
|
|
139
|
+
>
|
|
140
|
+
{props.resetLabel}
|
|
141
|
+
</button>
|
|
142
|
+
</span>
|
|
143
|
+
)
|
|
144
|
+
: null}
|
|
145
|
+
</div>
|
|
146
|
+
<input
|
|
147
|
+
id={props.id}
|
|
148
|
+
className={props.invalid ? css.inputInvalid : css.input}
|
|
149
|
+
type="text"
|
|
150
|
+
{...props.numeric === true ? { inputMode: 'numeric' as const } : {}}
|
|
151
|
+
{...props.invalid ? { 'aria-invalid': true } : {}}
|
|
152
|
+
value={props.text}
|
|
153
|
+
placeholder={props.placeholder ?? ''}
|
|
154
|
+
disabled={props.disabled}
|
|
155
|
+
onChange={(event) => { props.onEdit(event.target.value) }}
|
|
156
|
+
/>
|
|
157
|
+
<p className={props.invalid ? css.invalid : css.hint}>
|
|
158
|
+
{props.invalid ? props.invalidLabel : props.hint}
|
|
159
|
+
</p>
|
|
160
|
+
</div>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A staged boolean field: 继承 / 开 / 关. */
|
|
165
|
+
export function BooleanField(props: FieldProps & {
|
|
166
|
+
/** Copy for the inherit option. */
|
|
167
|
+
inheritLabel: string
|
|
168
|
+
/** Copy for the on option. */
|
|
169
|
+
onLabel: string
|
|
170
|
+
/** Copy for the off option. */
|
|
171
|
+
offLabel: string
|
|
172
|
+
}) {
|
|
173
|
+
return (
|
|
174
|
+
<div className={css.field}>
|
|
175
|
+
<div className={css.head}>
|
|
176
|
+
<label className={css.label} htmlFor={props.id}>{props.label}</label>
|
|
177
|
+
{props.overridden
|
|
178
|
+
? (
|
|
179
|
+
<span className={css.badges}>
|
|
180
|
+
<span className={css.badge}>{props.overriddenLabel}</span>
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
className={css.reset}
|
|
184
|
+
disabled={props.disabled}
|
|
185
|
+
onClick={props.onReset}
|
|
186
|
+
>
|
|
187
|
+
{props.resetLabel}
|
|
188
|
+
</button>
|
|
189
|
+
</span>
|
|
190
|
+
)
|
|
191
|
+
: null}
|
|
192
|
+
</div>
|
|
193
|
+
<select
|
|
194
|
+
id={props.id}
|
|
195
|
+
className={css.select}
|
|
196
|
+
value={props.text}
|
|
197
|
+
disabled={props.disabled}
|
|
198
|
+
onChange={(event) => { props.onEdit(event.target.value) }}
|
|
199
|
+
>
|
|
200
|
+
<option value="">{props.inheritLabel}</option>
|
|
201
|
+
<option value="true">{props.onLabel}</option>
|
|
202
|
+
<option value="false">{props.offLabel}</option>
|
|
203
|
+
</select>
|
|
204
|
+
<p className={css.hint}>{props.hint}</p>
|
|
205
|
+
</div>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The task-board settings card: whether the board announces itself in every
|
|
3
|
+
* agent's system prompt. Registers into the `settings.plugin.item` slot the
|
|
4
|
+
* plugin-configuration section renders, bound to the `task-board` settings
|
|
5
|
+
* namespace.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
9
|
+
import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
10
|
+
import { PluginSettingsCard, BooleanField } from './PluginSettingsCard.tsx'
|
|
11
|
+
import { CardForm, booleanField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
|
|
12
|
+
|
|
13
|
+
/** The task-board fields this card edits (the namespace's full schema). */
|
|
14
|
+
export interface TaskBoardSettings {
|
|
15
|
+
/** Master switch for the plugin. */
|
|
16
|
+
enabled?: boolean
|
|
17
|
+
/** Whether the board announces itself in every agent's system prompt. */
|
|
18
|
+
announceToAgent?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What the task-board card renders. */
|
|
22
|
+
export interface TaskBoardSettingsCardState extends CardShell {
|
|
23
|
+
/** Master switch. */
|
|
24
|
+
enabled: CardFieldState
|
|
25
|
+
/** System-prompt announcement flag. */
|
|
26
|
+
announceToAgent: CardFieldState
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The registration-side face the card's slot entry injects. */
|
|
30
|
+
export interface TaskBoardSettingsCardFace extends CardActions {
|
|
31
|
+
hooks: {
|
|
32
|
+
/** Card snapshot bound by the renderer as useTaskBoardSettingsCard. */
|
|
33
|
+
taskBoardSettingsCard: SnapshotStore<TaskBoardSettingsCardState>
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Bridges the `task-board` scope onto the card's staged form. */
|
|
38
|
+
export class TaskBoardSettingsCardController {
|
|
39
|
+
private readonly form: CardForm<TaskBoardSettings>
|
|
40
|
+
private readonly store: SnapshotStore<TaskBoardSettingsCardState>
|
|
41
|
+
|
|
42
|
+
/** @param scope - the bound settings scope for the `task-board` namespace. */
|
|
43
|
+
constructor(scope: SettingsScope<TaskBoardSettings>) {
|
|
44
|
+
this.form = new CardForm(scope, [
|
|
45
|
+
booleanField('enabled'),
|
|
46
|
+
booleanField('announceToAgent'),
|
|
47
|
+
])
|
|
48
|
+
this.store = this.form.bind(() => this.projection())
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private projection(): TaskBoardSettingsCardState {
|
|
52
|
+
return {
|
|
53
|
+
...this.form.shell(),
|
|
54
|
+
enabled: this.form.field('enabled'),
|
|
55
|
+
announceToAgent: this.form.field('announceToAgent'),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build the face the card's slot registration injects.
|
|
61
|
+
* @returns the card's snapshot and its form actions.
|
|
62
|
+
*/
|
|
63
|
+
inject(): TaskBoardSettingsCardFace {
|
|
64
|
+
return { hooks: { taskBoardSettingsCard: this.store }, ...this.form.actions() }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Props the renderer binds for the task-board card. */
|
|
69
|
+
export type TaskBoardSettingsCardProps =
|
|
70
|
+
PropsRuntime<'web-ui.plugin.item'>
|
|
71
|
+
& PropsLocale<'task-board'>
|
|
72
|
+
& InjectFace<TaskBoardSettingsCardFace>
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Render the task-board card.
|
|
76
|
+
* @param props - locale copy, the card snapshot, and its form actions.
|
|
77
|
+
* @returns the card.
|
|
78
|
+
*/
|
|
79
|
+
export function TaskBoardSettingsCard(props: TaskBoardSettingsCardProps) {
|
|
80
|
+
const { t } = props
|
|
81
|
+
const state = props.useTaskBoardSettingsCard(snapshot => snapshot)
|
|
82
|
+
const disabled = !state.writable
|
|
83
|
+
const fieldProps = {
|
|
84
|
+
overriddenLabel: t('settings.overridden'),
|
|
85
|
+
resetLabel: t('settings.reset'),
|
|
86
|
+
invalidLabel: t('settings.invalidNumber'),
|
|
87
|
+
disabled,
|
|
88
|
+
}
|
|
89
|
+
return (
|
|
90
|
+
<PluginSettingsCard
|
|
91
|
+
t={t}
|
|
92
|
+
titleKey="settings.title"
|
|
93
|
+
descriptionKey="settings.description"
|
|
94
|
+
state={state}
|
|
95
|
+
onSave={props.save}
|
|
96
|
+
onDiscard={props.discard}
|
|
97
|
+
>
|
|
98
|
+
<BooleanField
|
|
99
|
+
id="settings-task-board-enabled"
|
|
100
|
+
label={t('settings.enabled')}
|
|
101
|
+
hint={t('settings.enabledHint')}
|
|
102
|
+
inheritLabel={t('settings.inherit')}
|
|
103
|
+
onLabel={t('settings.on')}
|
|
104
|
+
offLabel={t('settings.off')}
|
|
105
|
+
{...fieldProps}
|
|
106
|
+
{...state.enabled}
|
|
107
|
+
onEdit={(text) => { props.edit('enabled', text) }}
|
|
108
|
+
onReset={() => { props.resetField('enabled') }}
|
|
109
|
+
/>
|
|
110
|
+
<BooleanField
|
|
111
|
+
id="settings-task-board-announce"
|
|
112
|
+
label={t('settings.announceToAgent')}
|
|
113
|
+
hint={t('settings.announceToAgentHint')}
|
|
114
|
+
inheritLabel={t('settings.inherit')}
|
|
115
|
+
onLabel={t('settings.on')}
|
|
116
|
+
offLabel={t('settings.off')}
|
|
117
|
+
{...fieldProps}
|
|
118
|
+
{...state.announceToAgent}
|
|
119
|
+
onEdit={(text) => { props.edit('announceToAgent', text) }}
|
|
120
|
+
onReset={() => { props.resetField('announceToAgent') }}
|
|
121
|
+
/>
|
|
122
|
+
</PluginSettingsCard>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic confirm dialog used by destructive actions (task delete).
|
|
3
|
+
*/
|
|
4
|
+
import { t } from '../locales.ts'
|
|
5
|
+
import css from '../board.module.css'
|
|
6
|
+
|
|
7
|
+
/** Confirm overlay props. */
|
|
8
|
+
export interface ConfirmDialogProps {
|
|
9
|
+
title: string
|
|
10
|
+
message: string
|
|
11
|
+
confirmLabel: string
|
|
12
|
+
/** Render the confirm button in the danger style. */
|
|
13
|
+
danger?: boolean
|
|
14
|
+
onCancel: () => void
|
|
15
|
+
onConfirm: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Small confirm overlay. */
|
|
19
|
+
export function ConfirmDialog({ title, message, confirmLabel, danger, onCancel, onConfirm }: ConfirmDialogProps) {
|
|
20
|
+
return (
|
|
21
|
+
<div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) onCancel() }}>
|
|
22
|
+
<div className={css.modal} role="alertdialog" aria-label={title}>
|
|
23
|
+
<h2 className={css.modalTitle}>{title}</h2>
|
|
24
|
+
<p className={css.confirmMessage}>{message}</p>
|
|
25
|
+
<footer className={css.modalFooter}>
|
|
26
|
+
<button type="button" className={css.ghostButton} onClick={onCancel}>
|
|
27
|
+
{t('delete.cancel')}
|
|
28
|
+
</button>
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
className={danger ? css.dangerButton : css.primaryButton}
|
|
32
|
+
onClick={onConfirm}
|
|
33
|
+
>
|
|
34
|
+
{confirmLabel}
|
|
35
|
+
</button>
|
|
36
|
+
</footer>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* New-task modal: title + description + the prompt that execution will send.
|
|
3
|
+
* Creates through the controller (which persists immediately).
|
|
4
|
+
*/
|
|
5
|
+
import { useState } from 'react'
|
|
6
|
+
import type { BoardController } from '../../core/controller.ts'
|
|
7
|
+
import { t } from '../locales.ts'
|
|
8
|
+
import css from '../board.module.css'
|
|
9
|
+
|
|
10
|
+
/** New-task form overlay. */
|
|
11
|
+
export function NewTaskModal({ controller, onClose }: { controller: BoardController; onClose: () => void }) {
|
|
12
|
+
const [title, setTitle] = useState('')
|
|
13
|
+
const [description, setDescription] = useState('')
|
|
14
|
+
const [prompt, setPrompt] = useState('')
|
|
15
|
+
const [error, setError] = useState<string | undefined>(undefined)
|
|
16
|
+
|
|
17
|
+
const submit = (): void => {
|
|
18
|
+
const task = controller.createTask({ title, description, prompt })
|
|
19
|
+
if (task === undefined) {
|
|
20
|
+
setError(t('new.required'))
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
onClose()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) onClose() }}>
|
|
28
|
+
<form
|
|
29
|
+
className={css.modal}
|
|
30
|
+
role="dialog"
|
|
31
|
+
aria-label={t('board.new')}
|
|
32
|
+
onSubmit={event => { event.preventDefault(); submit() }}
|
|
33
|
+
>
|
|
34
|
+
<h2 className={css.modalTitle}>{t('board.new')}</h2>
|
|
35
|
+
|
|
36
|
+
<label className={css.field}>
|
|
37
|
+
<span className={css.fieldLabel}>{t('new.title')}</span>
|
|
38
|
+
<input
|
|
39
|
+
className={css.input}
|
|
40
|
+
value={title}
|
|
41
|
+
autoFocus
|
|
42
|
+
placeholder={t('new.titlePlaceholder')}
|
|
43
|
+
onChange={event => { setTitle(event.target.value); setError(undefined) }}
|
|
44
|
+
/>
|
|
45
|
+
</label>
|
|
46
|
+
|
|
47
|
+
<label className={css.field}>
|
|
48
|
+
<span className={css.fieldLabel}>{t('new.description')}</span>
|
|
49
|
+
<textarea
|
|
50
|
+
className={css.input}
|
|
51
|
+
rows={3}
|
|
52
|
+
value={description}
|
|
53
|
+
placeholder={t('new.descriptionPlaceholder')}
|
|
54
|
+
onChange={event => { setDescription(event.target.value) }}
|
|
55
|
+
/>
|
|
56
|
+
</label>
|
|
57
|
+
|
|
58
|
+
<label className={css.field}>
|
|
59
|
+
<span className={css.fieldLabel}>{t('new.prompt')}</span>
|
|
60
|
+
<textarea
|
|
61
|
+
className={css.input}
|
|
62
|
+
rows={4}
|
|
63
|
+
value={prompt}
|
|
64
|
+
placeholder={t('new.promptPlaceholder')}
|
|
65
|
+
onChange={event => { setPrompt(event.target.value) }}
|
|
66
|
+
/>
|
|
67
|
+
</label>
|
|
68
|
+
|
|
69
|
+
{error !== undefined && <p className={css.formError}>{error}</p>}
|
|
70
|
+
|
|
71
|
+
<footer className={css.modalFooter}>
|
|
72
|
+
<button type="button" className={css.ghostButton} onClick={onClose}>
|
|
73
|
+
{t('new.cancel')}
|
|
74
|
+
</button>
|
|
75
|
+
<button type="submit" className={css.primaryButton}>
|
|
76
|
+
{t('new.submit')}
|
|
77
|
+
</button>
|
|
78
|
+
</footer>
|
|
79
|
+
</form>
|
|
80
|
+
</div>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Board view: the multi-column kanban that replaces the middle column while
|
|
3
|
+
* active. Cards open the task detail (never execute directly); the header
|
|
4
|
+
* offers filter, new-task, and a back-to-chat escape.
|
|
5
|
+
*/
|
|
6
|
+
import { useEffect, useState } from 'react'
|
|
7
|
+
import { selectedTaskOf, type BoardController } from '../../core/controller.ts'
|
|
8
|
+
import { COLUMNS, type TaskRecord, type TaskStatus } from '../../core/tasks.ts'
|
|
9
|
+
import { t, type TaskBoardKey } from '../locales.ts'
|
|
10
|
+
import css from '../board.module.css'
|
|
11
|
+
import { NewTaskModal } from './NewTaskModal.tsx'
|
|
12
|
+
import { TaskCard } from './TaskCard.tsx'
|
|
13
|
+
import { TaskDetail } from './TaskDetail.tsx'
|
|
14
|
+
|
|
15
|
+
/** Column status → locale key. */
|
|
16
|
+
const STATUS_KEY: Record<TaskStatus, TaskBoardKey> = {
|
|
17
|
+
backlog: 'board.status.backlog',
|
|
18
|
+
todo: 'board.status.todo',
|
|
19
|
+
running: 'board.status.running',
|
|
20
|
+
done: 'board.status.done',
|
|
21
|
+
failed: 'board.status.failed',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Case-insensitive title/description match. */
|
|
25
|
+
function matchesFilter(task: TaskRecord, filter: string): boolean {
|
|
26
|
+
if (filter.trim() === '') return true
|
|
27
|
+
const needle = filter.trim().toLowerCase()
|
|
28
|
+
return task.title.toLowerCase().includes(needle) || task.description.toLowerCase().includes(needle)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Board component; subscribes to the controller snapshot. */
|
|
32
|
+
export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
33
|
+
const [snapshot, setSnapshot] = useState(controller.getSnapshot())
|
|
34
|
+
useEffect(
|
|
35
|
+
() => controller.subscribe(() => setSnapshot(controller.getSnapshot())),
|
|
36
|
+
[controller],
|
|
37
|
+
)
|
|
38
|
+
const [filter, setFilter] = useState('')
|
|
39
|
+
const [showNew, setShowNew] = useState(false)
|
|
40
|
+
const selected = selectedTaskOf(snapshot)
|
|
41
|
+
const visible = snapshot.tasks.filter(task => matchesFilter(task, filter))
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className={css.board} data-dsh-taskboard-board="">
|
|
45
|
+
<header className={css.boardHeader}>
|
|
46
|
+
<h2 className={css.boardTitle}>{t('board.title')}</h2>
|
|
47
|
+
<input
|
|
48
|
+
className={css.search}
|
|
49
|
+
type="search"
|
|
50
|
+
placeholder={t('board.search')}
|
|
51
|
+
value={filter}
|
|
52
|
+
onChange={event => { setFilter(event.target.value) }}
|
|
53
|
+
aria-label={t('board.search')}
|
|
54
|
+
/>
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
className={css.primaryButton}
|
|
58
|
+
onClick={() => { setShowNew(true) }}
|
|
59
|
+
>
|
|
60
|
+
+ {t('board.new')}
|
|
61
|
+
</button>
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
className={css.ghostButton}
|
|
65
|
+
onClick={() => { controller.closeBoard() }}
|
|
66
|
+
>
|
|
67
|
+
{t('board.close')}
|
|
68
|
+
</button>
|
|
69
|
+
</header>
|
|
70
|
+
|
|
71
|
+
<div className={css.columns}>
|
|
72
|
+
{COLUMNS.map(column => {
|
|
73
|
+
const tasks = visible.filter(task => task.status === column.status)
|
|
74
|
+
return (
|
|
75
|
+
<section key={column.status} className={css.column} data-status={column.status}>
|
|
76
|
+
<header className={css.columnHeader}>
|
|
77
|
+
<span className={css.statusDot} data-status={column.status} aria-hidden="true" />
|
|
78
|
+
<h3 className={css.columnTitle}>{t(STATUS_KEY[column.status])}</h3>
|
|
79
|
+
<span className={css.columnCount}>{tasks.length}</span>
|
|
80
|
+
</header>
|
|
81
|
+
<div className={css.cards}>
|
|
82
|
+
{tasks.map(task => (
|
|
83
|
+
<TaskCard
|
|
84
|
+
key={task.id}
|
|
85
|
+
task={task}
|
|
86
|
+
onClick={() => { controller.openTask(task.id) }}
|
|
87
|
+
/>
|
|
88
|
+
))}
|
|
89
|
+
{tasks.length === 0 && <div className={css.columnEmpty}>{t('board.empty')}</div>}
|
|
90
|
+
</div>
|
|
91
|
+
</section>
|
|
92
|
+
)
|
|
93
|
+
})}
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
{selected !== undefined && (
|
|
97
|
+
<TaskDetail controller={controller} task={selected} />
|
|
98
|
+
)}
|
|
99
|
+
{showNew && (
|
|
100
|
+
<NewTaskModal
|
|
101
|
+
controller={controller}
|
|
102
|
+
onClose={() => { setShowNew(false) }}
|
|
103
|
+
/>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task card: the board's column item. Clicking opens the task detail — it
|
|
3
|
+
* never executes anything directly (detail holds the Run button).
|
|
4
|
+
*/
|
|
5
|
+
import type { TaskRecord } from '../../core/tasks.ts'
|
|
6
|
+
import { executionLabel } from '../../core/tasks.ts'
|
|
7
|
+
import { t } from '../locales.ts'
|
|
8
|
+
import css from '../board.module.css'
|
|
9
|
+
|
|
10
|
+
/** Compact relative/absolute time label. */
|
|
11
|
+
export function formatTime(ms: number): string {
|
|
12
|
+
const date = new Date(ms)
|
|
13
|
+
const now = Date.now()
|
|
14
|
+
const minutes = Math.floor((now - ms) / 60000)
|
|
15
|
+
if (minutes < 1) return t('time.justNow')
|
|
16
|
+
if (minutes < 60) return `${minutes}m`
|
|
17
|
+
if (minutes < 60 * 24) return `${Math.floor(minutes / 60)}h`
|
|
18
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** One card in a column. */
|
|
22
|
+
export function TaskCard({ task, onClick }: { task: TaskRecord; onClick: () => void }) {
|
|
23
|
+
const latest = task.executions[task.executions.length - 1]
|
|
24
|
+
const runs = task.executions.length
|
|
25
|
+
return (
|
|
26
|
+
<button
|
|
27
|
+
type="button"
|
|
28
|
+
className={css.card}
|
|
29
|
+
data-status={task.status}
|
|
30
|
+
onClick={onClick}
|
|
31
|
+
title={task.description !== '' ? task.description : task.title}
|
|
32
|
+
>
|
|
33
|
+
<span className={css.cardTitle}>{task.title}</span>
|
|
34
|
+
{task.description !== '' && <span className={css.cardExcerpt}>{task.description}</span>}
|
|
35
|
+
<span className={css.cardMeta}>
|
|
36
|
+
<span className={css.cardTime}>{t('board.updated')} {formatTime(task.updatedAt)}</span>
|
|
37
|
+
{task.schedule?.enabled === true && (
|
|
38
|
+
<span
|
|
39
|
+
className={css.cardSchedule}
|
|
40
|
+
title={task.schedule.nextRunAt !== undefined
|
|
41
|
+
? `${t('card.scheduled')} · ${new Date(task.schedule.nextRunAt).toLocaleString()}`
|
|
42
|
+
: t('card.scheduled')}
|
|
43
|
+
>
|
|
44
|
+
{t('card.scheduled')}
|
|
45
|
+
</span>
|
|
46
|
+
)}
|
|
47
|
+
{latest !== undefined && (
|
|
48
|
+
<span className={css.cardRun} data-result={latest.result}>
|
|
49
|
+
{runs} {t('board.runs')}
|
|
50
|
+
</span>
|
|
51
|
+
)}
|
|
52
|
+
{latest?.sessionId !== undefined && (
|
|
53
|
+
<span className={css.cardSession} title={latest.sessionId}>⌁</span>
|
|
54
|
+
)}
|
|
55
|
+
{task.status === 'running' && <span className={css.cardSpinner} aria-hidden="true" />}
|
|
56
|
+
</span>
|
|
57
|
+
{latest !== undefined && executionLabel(latest) === 'running' && (
|
|
58
|
+
<span className={css.cardRunningLabel}>{t('detail.result.running')}…</span>
|
|
59
|
+
)}
|
|
60
|
+
</button>
|
|
61
|
+
)
|
|
62
|
+
}
|