@linxin666/dsh-client-ui-task-board 0.1.16 → 0.1.18
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 +11 -7
- package/README.zh.md +11 -7
- package/lib/client.js +821 -145
- package/lib/client.js.map +1 -1
- package/lib/index.js +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +11 -0
- package/lib/types/client/locales.d.ts +21 -0
- package/lib/types/client/schedule-presets.d.ts +10 -0
- package/lib/types/core/controller.d.ts +57 -1
- package/lib/types/core/execution.d.ts +65 -4
- package/lib/types/core/scheduler.d.ts +7 -0
- package/lib/types/core/store.d.ts +1 -1
- package/lib/types/core/tasks.d.ts +43 -0
- package/lib/types/core/use-cases/task-archive.d.ts +22 -0
- package/lib/types/core/use-cases/task-create.d.ts +1 -6
- package/lib/types/core/use-cases/task-update.d.ts +9 -4
- package/lib/types/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/client/PluginSettingsCard.tsx +50 -47
- package/src/client/board/NewTaskModal.tsx +136 -3
- package/src/client/board/TaskBoard.tsx +48 -19
- package/src/client/board/TaskDetail.tsx +97 -8
- package/src/client/board.module.css +16 -0
- package/src/client/index.ts +71 -2
- package/src/client/locales.ts +42 -0
- package/src/client/schedule-presets.ts +13 -0
- package/src/client/settings-card.module.css +9 -0
- package/src/core/controller.ts +92 -2
- package/src/core/execution.ts +179 -12
- package/src/core/scheduler.ts +15 -0
- package/src/core/store.ts +16 -2
- package/src/core/tasks.ts +56 -0
- package/src/core/use-cases/task-archive.ts +48 -0
- package/src/core/use-cases/task-create.ts +12 -3
- package/src/core/use-cases/task-update.ts +34 -5
- package/src/index.ts +1 -1
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
* New-task modal: title + description + the prompt that execution will send.
|
|
3
3
|
* Creates through the controller (which persists immediately).
|
|
4
4
|
*/
|
|
5
|
-
import { useState } from 'react'
|
|
5
|
+
import { useEffect, useState } from 'react'
|
|
6
6
|
import type { BoardController } from '../../core/controller.ts'
|
|
7
|
-
import {
|
|
7
|
+
import { isValidCron, nextRunAtMs } from '../../core/schedule.ts'
|
|
8
|
+
import { TASK_PERMISSIONS, type TaskPermission } from '../../core/tasks.ts'
|
|
9
|
+
import { t, type TaskBoardKey } from '../locales.ts'
|
|
10
|
+
import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
|
|
8
11
|
import css from '../board.module.css'
|
|
9
12
|
|
|
10
13
|
/** New-task form overlay. */
|
|
@@ -12,10 +15,39 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
12
15
|
const [title, setTitle] = useState('')
|
|
13
16
|
const [description, setDescription] = useState('')
|
|
14
17
|
const [prompt, setPrompt] = useState('')
|
|
18
|
+
const [workspaceId, setWorkspaceId] = useState('')
|
|
19
|
+
const [mode, setMode] = useState('')
|
|
20
|
+
const [permission, setPermission] = useState('')
|
|
21
|
+
const [scheduleEnabled, setScheduleEnabled] = useState(false)
|
|
22
|
+
const [scheduleCron, setScheduleCron] = useState('')
|
|
23
|
+
const [scheduleError, setScheduleError] = useState<string | undefined>(undefined)
|
|
15
24
|
const [error, setError] = useState<string | undefined>(undefined)
|
|
25
|
+
const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
|
|
26
|
+
|
|
27
|
+
// The workspace list and preset roster arrive from the runtime after mount;
|
|
28
|
+
// follow them so the pickers never freeze on an empty snapshot.
|
|
29
|
+
useEffect(
|
|
30
|
+
() => controller.subscribe(() => setOptions(controller.getSnapshot().executionOptions)),
|
|
31
|
+
[controller],
|
|
32
|
+
)
|
|
16
33
|
|
|
17
34
|
const submit = (): void => {
|
|
18
|
-
|
|
35
|
+
if (scheduleEnabled) {
|
|
36
|
+
const cron = scheduleCron.trim()
|
|
37
|
+
if (cron === '' || !isValidCron(cron)) {
|
|
38
|
+
setScheduleError(t('detail.schedule.invalid'))
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const task = controller.createTask({
|
|
43
|
+
title,
|
|
44
|
+
description,
|
|
45
|
+
prompt,
|
|
46
|
+
workspaceId: workspaceId === '' ? undefined : workspaceId,
|
|
47
|
+
mode: mode === '' ? undefined : mode,
|
|
48
|
+
permission: permission === '' ? undefined : permission as TaskPermission,
|
|
49
|
+
schedule: scheduleEnabled ? { enabled: true, cron: scheduleCron.trim() } : undefined,
|
|
50
|
+
})
|
|
19
51
|
if (task === undefined) {
|
|
20
52
|
setError(t('new.required'))
|
|
21
53
|
return
|
|
@@ -23,6 +55,11 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
23
55
|
onClose()
|
|
24
56
|
}
|
|
25
57
|
|
|
58
|
+
/** Next-run preview for a valid armed cron (creation-time only). */
|
|
59
|
+
const scheduleNextRun = scheduleEnabled && scheduleCron.trim() !== '' && isValidCron(scheduleCron)
|
|
60
|
+
? nextRunAtMs(scheduleCron, Date.now())
|
|
61
|
+
: undefined
|
|
62
|
+
|
|
26
63
|
return (
|
|
27
64
|
<div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) onClose() }}>
|
|
28
65
|
<form
|
|
@@ -66,6 +103,102 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
66
103
|
/>
|
|
67
104
|
</label>
|
|
68
105
|
|
|
106
|
+
<label className={css.field}>
|
|
107
|
+
<span className={css.fieldLabel}>{t('new.workspace')}</span>
|
|
108
|
+
<select
|
|
109
|
+
className={css.select}
|
|
110
|
+
value={workspaceId}
|
|
111
|
+
onChange={event => { setWorkspaceId(event.target.value) }}
|
|
112
|
+
>
|
|
113
|
+
<option value="">{t('exec.workspace.recent')}</option>
|
|
114
|
+
{options.workspaces.map(workspace => (
|
|
115
|
+
<option key={workspace.workspaceId} value={workspace.workspaceId}>{workspace.title}</option>
|
|
116
|
+
))}
|
|
117
|
+
</select>
|
|
118
|
+
</label>
|
|
119
|
+
|
|
120
|
+
<label className={css.field}>
|
|
121
|
+
<span className={css.fieldLabel}>{t('new.mode')}</span>
|
|
122
|
+
<select
|
|
123
|
+
className={css.select}
|
|
124
|
+
value={mode}
|
|
125
|
+
onChange={event => { setMode(event.target.value) }}
|
|
126
|
+
>
|
|
127
|
+
<option value="">{t('exec.mode.default')}</option>
|
|
128
|
+
{options.presets.map(preset => (
|
|
129
|
+
<option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
|
|
130
|
+
{preset.name ?? preset.id}
|
|
131
|
+
{preset.isDefault ? t('exec.mode.defaultSuffix') : ''}
|
|
132
|
+
{preset.broken !== undefined ? t('exec.mode.brokenSuffix') : ''}
|
|
133
|
+
</option>
|
|
134
|
+
))}
|
|
135
|
+
</select>
|
|
136
|
+
</label>
|
|
137
|
+
|
|
138
|
+
<label className={css.field}>
|
|
139
|
+
<span className={css.fieldLabel}>{t('new.permission')}</span>
|
|
140
|
+
<select
|
|
141
|
+
className={css.select}
|
|
142
|
+
value={permission}
|
|
143
|
+
onChange={event => { setPermission(event.target.value) }}
|
|
144
|
+
>
|
|
145
|
+
<option value="">{t('exec.permission.default')}</option>
|
|
146
|
+
{TASK_PERMISSIONS.map(id => (
|
|
147
|
+
<option key={id} value={id}>{t(`exec.permission.${id}` as TaskBoardKey)}</option>
|
|
148
|
+
))}
|
|
149
|
+
</select>
|
|
150
|
+
</label>
|
|
151
|
+
|
|
152
|
+
<section className={css.detailSection}>
|
|
153
|
+
<h4>{t('detail.schedule')}</h4>
|
|
154
|
+
<label className={css.scheduleToggle}>
|
|
155
|
+
<input
|
|
156
|
+
type="checkbox"
|
|
157
|
+
checked={scheduleEnabled}
|
|
158
|
+
onChange={event => {
|
|
159
|
+
setScheduleEnabled(event.target.checked)
|
|
160
|
+
if (!event.target.checked) setScheduleError(undefined)
|
|
161
|
+
}}
|
|
162
|
+
/>
|
|
163
|
+
<span>{t('detail.schedule.enable')}</span>
|
|
164
|
+
</label>
|
|
165
|
+
{scheduleEnabled && (
|
|
166
|
+
<>
|
|
167
|
+
<div className={css.scheduleRow}>
|
|
168
|
+
<input
|
|
169
|
+
className={`${css.input} ${css.scheduleInput}${scheduleError !== undefined ? ` ${css.scheduleInputInvalid}` : ''}`}
|
|
170
|
+
value={scheduleCron}
|
|
171
|
+
placeholder="0 9 * * *"
|
|
172
|
+
spellCheck={false}
|
|
173
|
+
aria-label={t('detail.schedule.cron')}
|
|
174
|
+
onChange={event => { setScheduleCron(event.target.value); setScheduleError(undefined) }}
|
|
175
|
+
/>
|
|
176
|
+
<select
|
|
177
|
+
className={css.schedulePreset}
|
|
178
|
+
value=""
|
|
179
|
+
aria-label={t('detail.schedule.presets')}
|
|
180
|
+
onChange={event => {
|
|
181
|
+
if (event.target.value === '') return
|
|
182
|
+
setScheduleCron(event.target.value)
|
|
183
|
+
setScheduleError(undefined)
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
<option value="">{t('detail.schedule.presets')}…</option>
|
|
187
|
+
{SCHEDULE_PRESETS.map(preset => (
|
|
188
|
+
<option key={preset.cron} value={preset.cron}>{t(preset.label)}</option>
|
|
189
|
+
))}
|
|
190
|
+
</select>
|
|
191
|
+
</div>
|
|
192
|
+
{scheduleError !== undefined && <p className={css.formError}>{scheduleError}</p>}
|
|
193
|
+
{scheduleError === undefined && scheduleNextRun !== undefined && (
|
|
194
|
+
<p className={css.scheduleMeta}>
|
|
195
|
+
{t('detail.schedule.nextRun')} {new Date(scheduleNextRun).toLocaleString()}
|
|
196
|
+
</p>
|
|
197
|
+
)}
|
|
198
|
+
</>
|
|
199
|
+
)}
|
|
200
|
+
</section>
|
|
201
|
+
|
|
69
202
|
{error !== undefined && <p className={css.formError}>{error}</p>}
|
|
70
203
|
|
|
71
204
|
<footer className={css.modalFooter}>
|
|
@@ -49,7 +49,12 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
49
49
|
const [filter, setFilter] = useState('')
|
|
50
50
|
const [showNew, setShowNew] = useState(false)
|
|
51
51
|
const selected = selectedTaskOf(snapshot)
|
|
52
|
-
const
|
|
52
|
+
const archiveView = snapshot.archiveView
|
|
53
|
+
// Archived tasks leave the columns; the archive view shows them instead.
|
|
54
|
+
const visible = snapshot.tasks.filter(task =>
|
|
55
|
+
(archiveView ? task.archivedAt !== undefined : task.archivedAt === undefined)
|
|
56
|
+
&& matchesFilter(task, filter),
|
|
57
|
+
)
|
|
53
58
|
const openTask = useCallback((id: string): void => { controller.openTask(id) }, [controller])
|
|
54
59
|
|
|
55
60
|
return (
|
|
@@ -64,6 +69,15 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
64
69
|
onChange={event => { setFilter(event.target.value) }}
|
|
65
70
|
aria-label={t('board.search')}
|
|
66
71
|
/>
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
className={archiveView ? css.primaryButton : css.ghostButton}
|
|
75
|
+
onClick={() => { controller.toggleArchiveView() }}
|
|
76
|
+
>
|
|
77
|
+
{archiveView
|
|
78
|
+
? t('board.backToBoard')
|
|
79
|
+
: t('board.archiveView', { count: String(snapshot.tasks.filter(task => task.archivedAt !== undefined).length) })}
|
|
80
|
+
</button>
|
|
67
81
|
<button
|
|
68
82
|
type="button"
|
|
69
83
|
className={css.primaryButton}
|
|
@@ -81,24 +95,39 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
81
95
|
</header>
|
|
82
96
|
|
|
83
97
|
<div className={css.columns}>
|
|
84
|
-
{
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
98
|
+
{archiveView ? (
|
|
99
|
+
<section className={css.column} data-status="archived">
|
|
100
|
+
<header className={css.columnHeader}>
|
|
101
|
+
<h3 className={css.columnTitle}>{t('board.archive')}</h3>
|
|
102
|
+
<span className={css.columnCount}>{visible.length}</span>
|
|
103
|
+
</header>
|
|
104
|
+
<div className={css.cards}>
|
|
105
|
+
{visible.map(task => (
|
|
106
|
+
<MemoTaskCard key={task.id} task={task} onOpen={openTask} />
|
|
107
|
+
))}
|
|
108
|
+
{visible.length === 0 && <div className={css.columnEmpty}>{t('archive.empty')}</div>}
|
|
109
|
+
</div>
|
|
110
|
+
</section>
|
|
111
|
+
) : (
|
|
112
|
+
COLUMNS.map(column => {
|
|
113
|
+
const tasks = visible.filter(task => task.status === column.status)
|
|
114
|
+
return (
|
|
115
|
+
<section key={column.status} className={css.column} data-status={column.status}>
|
|
116
|
+
<header className={css.columnHeader}>
|
|
117
|
+
<span className={css.statusDot} data-status={column.status} aria-hidden="true" />
|
|
118
|
+
<h3 className={css.columnTitle}>{t(STATUS_KEY[column.status])}</h3>
|
|
119
|
+
<span className={css.columnCount}>{tasks.length}</span>
|
|
120
|
+
</header>
|
|
121
|
+
<div className={css.cards}>
|
|
122
|
+
{tasks.map(task => (
|
|
123
|
+
<MemoTaskCard key={task.id} task={task} onOpen={openTask} />
|
|
124
|
+
))}
|
|
125
|
+
{tasks.length === 0 && <div className={css.columnEmpty}>{t('board.empty')}</div>}
|
|
126
|
+
</div>
|
|
127
|
+
</section>
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
)}
|
|
102
131
|
</div>
|
|
103
132
|
|
|
104
133
|
{selected !== undefined && (
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
import { useEffect, useState } from 'react'
|
|
8
8
|
import type { BoardController } from '../../core/controller.ts'
|
|
9
9
|
import { isValidCron } from '../../core/schedule.ts'
|
|
10
|
-
import { MANUAL_STATUSES, type ExecutionRecord, type TaskRecord, type TaskStatus } from '../../core/tasks.ts'
|
|
10
|
+
import { MANUAL_STATUSES, TASK_PERMISSIONS, type ExecutionRecord, type TaskPermission, type TaskRecord, type TaskStatus } from '../../core/tasks.ts'
|
|
11
11
|
import { t, type TaskBoardKey } from '../locales.ts'
|
|
12
|
+
import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
|
|
12
13
|
import css from '../board.module.css'
|
|
13
14
|
import { ConfirmDialog } from './ConfirmDialog.tsx'
|
|
14
15
|
import { formatTime } from './TaskCard.tsx'
|
|
@@ -58,13 +59,73 @@ function ExecutionRow({ execution, onOpen }: { execution: ExecutionRecord; onOpe
|
|
|
58
59
|
)
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
62
|
+
/** The execution-target editor: workspace / mode / permission pickers. */
|
|
63
|
+
function ExecutionSettingsSection({ controller, task }: { controller: BoardController; task: TaskRecord }) {
|
|
64
|
+
const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
|
|
65
|
+
useEffect(
|
|
66
|
+
() => controller.subscribe(() => setOptions(controller.getSnapshot().executionOptions)),
|
|
67
|
+
[controller],
|
|
68
|
+
)
|
|
69
|
+
const workspaceId = task.workspaceId ?? ''
|
|
70
|
+
const mode = task.mode ?? ''
|
|
71
|
+
const permission = task.permission ?? ''
|
|
72
|
+
// A pinned target may disappear from the runtime (workspace deleted,
|
|
73
|
+
// preset removed); keep it selectable as a stale row instead of silently
|
|
74
|
+
// dropping it, so the user sees exactly what the task will ask for.
|
|
75
|
+
const workspaceKnown = workspaceId === '' || options.workspaces.some(item => item.workspaceId === workspaceId)
|
|
76
|
+
const modeKnown = mode === '' || options.presets.some(item => item.id === mode)
|
|
77
|
+
return (
|
|
78
|
+
<section className={css.detailSection}>
|
|
79
|
+
<h4>{t('detail.executionSettings')}</h4>
|
|
80
|
+
<p className={css.detailText}>{t('exec.hint')}</p>
|
|
81
|
+
<label className={css.field}>
|
|
82
|
+
<span className={css.fieldLabel}>{t('new.workspace')}</span>
|
|
83
|
+
<select
|
|
84
|
+
className={css.select}
|
|
85
|
+
value={workspaceId}
|
|
86
|
+
onChange={event => { controller.updateTask(task.id, { workspaceId: event.target.value }) }}
|
|
87
|
+
>
|
|
88
|
+
<option value="">{t('exec.workspace.recent')}</option>
|
|
89
|
+
{!workspaceKnown && <option value={workspaceId}>{workspaceId}{t('exec.mode.removed')}</option>}
|
|
90
|
+
{options.workspaces.map(workspace => (
|
|
91
|
+
<option key={workspace.workspaceId} value={workspace.workspaceId}>{workspace.title}</option>
|
|
92
|
+
))}
|
|
93
|
+
</select>
|
|
94
|
+
</label>
|
|
95
|
+
<label className={css.field}>
|
|
96
|
+
<span className={css.fieldLabel}>{t('new.mode')}</span>
|
|
97
|
+
<select
|
|
98
|
+
className={css.select}
|
|
99
|
+
value={mode}
|
|
100
|
+
onChange={event => { controller.updateTask(task.id, { mode: event.target.value }) }}
|
|
101
|
+
>
|
|
102
|
+
<option value="">{t('exec.mode.default')}</option>
|
|
103
|
+
{!modeKnown && <option value={mode}>{mode}{t('exec.mode.removed')}</option>}
|
|
104
|
+
{options.presets.map(preset => (
|
|
105
|
+
<option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
|
|
106
|
+
{preset.name ?? preset.id}
|
|
107
|
+
{preset.isDefault ? t('exec.mode.defaultSuffix') : ''}
|
|
108
|
+
{preset.broken !== undefined ? t('exec.mode.brokenSuffix') : ''}
|
|
109
|
+
</option>
|
|
110
|
+
))}
|
|
111
|
+
</select>
|
|
112
|
+
</label>
|
|
113
|
+
<label className={css.field}>
|
|
114
|
+
<span className={css.fieldLabel}>{t('new.permission')}</span>
|
|
115
|
+
<select
|
|
116
|
+
className={css.select}
|
|
117
|
+
value={permission}
|
|
118
|
+
onChange={event => { controller.updateTask(task.id, { permission: event.target.value === '' ? undefined : event.target.value as TaskPermission }) }}
|
|
119
|
+
>
|
|
120
|
+
<option value="">{t('exec.permission.default')}</option>
|
|
121
|
+
{TASK_PERMISSIONS.map(id => (
|
|
122
|
+
<option key={id} value={id}>{t(`exec.permission.${id}` as TaskBoardKey)}</option>
|
|
123
|
+
))}
|
|
124
|
+
</select>
|
|
125
|
+
</label>
|
|
126
|
+
</section>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
68
129
|
|
|
69
130
|
/** The scheduled-runs editor: enable toggle, cron input + presets, next-run info. */
|
|
70
131
|
function ScheduleSection({ controller, task }: { controller: BoardController; task: TaskRecord }) {
|
|
@@ -203,6 +264,8 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
203
264
|
<pre className={css.promptBlock}>{current.prompt !== '' ? current.prompt : current.title}</pre>
|
|
204
265
|
</section>
|
|
205
266
|
|
|
267
|
+
<ExecutionSettingsSection controller={controller} task={current} />
|
|
268
|
+
|
|
206
269
|
<ScheduleSection controller={controller} task={current} />
|
|
207
270
|
|
|
208
271
|
<section className={css.detailSection}>
|
|
@@ -254,6 +317,31 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
254
317
|
>
|
|
255
318
|
{current.executions.length === 0 ? t('detail.run') : t('detail.rerun')}
|
|
256
319
|
</button>
|
|
320
|
+
{current.archivedAt !== undefined ? (
|
|
321
|
+
<button
|
|
322
|
+
type="button"
|
|
323
|
+
className={css.primaryButton}
|
|
324
|
+
onClick={() => {
|
|
325
|
+
controller.restoreTask(current.id)
|
|
326
|
+
controller.closeTask()
|
|
327
|
+
}}
|
|
328
|
+
>
|
|
329
|
+
{t('detail.restore')}
|
|
330
|
+
</button>
|
|
331
|
+
) : (
|
|
332
|
+
(current.status === 'done' || current.status === 'failed') && (
|
|
333
|
+
<button
|
|
334
|
+
type="button"
|
|
335
|
+
className={css.ghostButton}
|
|
336
|
+
onClick={() => {
|
|
337
|
+
controller.archiveTask(current.id)
|
|
338
|
+
controller.closeTask()
|
|
339
|
+
}}
|
|
340
|
+
>
|
|
341
|
+
{t('detail.archive')}
|
|
342
|
+
</button>
|
|
343
|
+
)
|
|
344
|
+
)}
|
|
257
345
|
<button
|
|
258
346
|
type="button"
|
|
259
347
|
className={css.dangerButton}
|
|
@@ -263,6 +351,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
263
351
|
</button>
|
|
264
352
|
<span className={css.detailMeta}>
|
|
265
353
|
{t('board.created')} {formatTime(current.createdAt)}
|
|
354
|
+
{current.archivedAt !== undefined && ` · ${t('detail.archivedAt', { time: formatTime(current.archivedAt) })}`}
|
|
266
355
|
</span>
|
|
267
356
|
</footer>
|
|
268
357
|
</div>
|
|
@@ -512,6 +512,18 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
|
|
|
512
512
|
border-color: var(--dsw-alias-state-business-primary);
|
|
513
513
|
}
|
|
514
514
|
|
|
515
|
+
.select {
|
|
516
|
+
padding: 7px 10px;
|
|
517
|
+
font-size: 13px;
|
|
518
|
+
color: var(--dsw-alias-label-primary);
|
|
519
|
+
background: var(--dsw-specific-input-major);
|
|
520
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
521
|
+
border-radius: 8px;
|
|
522
|
+
outline: none;
|
|
523
|
+
font-family: inherit;
|
|
524
|
+
max-width: 100%;
|
|
525
|
+
}
|
|
526
|
+
|
|
515
527
|
.input::placeholder {
|
|
516
528
|
color: var(--dsw-alias-label-tertiary);
|
|
517
529
|
}
|
|
@@ -775,6 +787,7 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
|
|
|
775
787
|
.linkButton:focus-visible,
|
|
776
788
|
.search:focus-visible,
|
|
777
789
|
.input:focus-visible,
|
|
790
|
+
.select:focus-visible,
|
|
778
791
|
.schedulePreset:focus-visible,
|
|
779
792
|
.scheduleToggle input:focus-visible {
|
|
780
793
|
outline: 2px solid var(--dsw-alias-state-business-primary);
|
|
@@ -790,6 +803,7 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
|
|
|
790
803
|
.linkButton,
|
|
791
804
|
.search,
|
|
792
805
|
.input,
|
|
806
|
+
.select,
|
|
793
807
|
.schedulePreset,
|
|
794
808
|
.scheduleToggle input {
|
|
795
809
|
transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease, outline-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
|
|
@@ -834,6 +848,7 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
|
|
|
834
848
|
|
|
835
849
|
/* --- search box focus (border tint + themed ring above) --- */
|
|
836
850
|
.search:focus,
|
|
851
|
+
.select:focus,
|
|
837
852
|
.schedulePreset:focus {
|
|
838
853
|
border-color: var(--dsw-alias-state-business-primary);
|
|
839
854
|
}
|
|
@@ -855,6 +870,7 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
|
|
|
855
870
|
.linkButton,
|
|
856
871
|
.search,
|
|
857
872
|
.input,
|
|
873
|
+
.select,
|
|
858
874
|
.schedulePreset,
|
|
859
875
|
.scheduleToggle input {
|
|
860
876
|
transition: none;
|
package/src/client/index.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* plugin must not take the GUI down.
|
|
10
10
|
*/
|
|
11
11
|
import type { ClientContext, SessionId, SettingsScope, SettingsScopeSpec, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
|
12
|
-
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
|
12
|
+
import type { ConnectionHandle, PromptContentPart } from '@deepseek-ai/dsh-client-connection/client'
|
|
13
13
|
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
|
14
14
|
// Type-only: pulls the locale plugin's Context merge (ctx.locale) and its
|
|
15
15
|
// LocaleNamespaceMap merge table.
|
|
@@ -116,12 +116,40 @@ export function apply(ctx: ClientContext): void {
|
|
|
116
116
|
const exec = new ExecutionService({
|
|
117
117
|
sessions: {
|
|
118
118
|
list: sessions.list,
|
|
119
|
-
binding: id =>
|
|
119
|
+
binding: id => {
|
|
120
|
+
const binding = sessions.binding(id as SessionId)
|
|
121
|
+
if (binding === undefined) return undefined
|
|
122
|
+
const { session } = binding
|
|
123
|
+
return {
|
|
124
|
+
session: {
|
|
125
|
+
rename: title => session.rename(title),
|
|
126
|
+
prompt: (content, mode) =>
|
|
127
|
+
session.prompt(content as PromptContentPart[], mode).then(result =>
|
|
128
|
+
result.ok ? { ok: true as const } : { ok: false as const, error: result.error }),
|
|
129
|
+
command: line =>
|
|
130
|
+
session.command(line).then(result =>
|
|
131
|
+
result.ok ? { ok: true as const, matched: result.value.matched } : { ok: false as const, error: result.error }),
|
|
132
|
+
getSnapshot: () => session.getSnapshot(),
|
|
133
|
+
subscribe: fn => session.subscribe(fn),
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
noteAgentPreset: (sessionId, agentPreset) => sessions.noteAgentPreset(sessionId as SessionId, agentPreset),
|
|
120
138
|
},
|
|
121
139
|
workspaces: {
|
|
122
140
|
list: workspaces.list,
|
|
123
141
|
connectWorkspace: id => workspaces.connectWorkspace(id as WorkspaceId),
|
|
124
142
|
},
|
|
143
|
+
presets: {
|
|
144
|
+
select: async (sessionId, agentPreset) => {
|
|
145
|
+
try {
|
|
146
|
+
const response = await connection.api.agentPresets.select({ sessionId: sessionId as SessionId, agentPreset })
|
|
147
|
+
return response.result.ok ? { ok: true as const } : { ok: false as const, error: response.result.error }
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return { ok: false as const, error }
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
},
|
|
125
153
|
history: {
|
|
126
154
|
loadTail: async sessionId => {
|
|
127
155
|
const response = await connection.api.sessions.history({
|
|
@@ -150,6 +178,9 @@ export function apply(ctx: ClientContext): void {
|
|
|
150
178
|
// not-yet-ready runtime; tab visibility recovery ticks immediately.
|
|
151
179
|
const scheduler = new SchedulerService({
|
|
152
180
|
tasks: () => controller.getSnapshot().tasks,
|
|
181
|
+
// Re-read the persisted ledger before every fire decision: a task
|
|
182
|
+
// deleted in another tab must never fire from a stale in-memory copy.
|
|
183
|
+
refresh: () => controller.reloadFromStore(),
|
|
153
184
|
now: () => Date.now(),
|
|
154
185
|
runTask: id => controller.runTask(id),
|
|
155
186
|
applySchedule: (id, nextRunAt, lastTriggeredAt) =>
|
|
@@ -163,6 +194,44 @@ export function apply(ctx: ClientContext): void {
|
|
|
163
194
|
scheduler.start()
|
|
164
195
|
|
|
165
196
|
const disposers: Array<() => void> = []
|
|
197
|
+
|
|
198
|
+
// Execution-target option feeds: the workspace list drives the workspace
|
|
199
|
+
// picker, and the agent-preset roster drives the mode picker. Both are
|
|
200
|
+
// runtime facts (not ledger state), so the wiring pushes them into the
|
|
201
|
+
// controller on change; the preset roster is re-read after reconnects
|
|
202
|
+
// because a reconnect may serve a different deployment.
|
|
203
|
+
const pushWorkspaceOptions = (): void => {
|
|
204
|
+
const snapshot = workspaces.list.getSnapshot()
|
|
205
|
+
controller.setExecutionOptions({
|
|
206
|
+
workspaces: snapshot.items.map(item => ({
|
|
207
|
+
workspaceId: item.workspaceId,
|
|
208
|
+
title: item.title !== '' ? item.title : item.path,
|
|
209
|
+
})),
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
pushWorkspaceOptions()
|
|
213
|
+
disposers.push(workspaces.list.subscribe(pushWorkspaceOptions))
|
|
214
|
+
const pushPresetOptions = async (): Promise<void> => {
|
|
215
|
+
try {
|
|
216
|
+
const response = await connection.api.agentPresets.list({})
|
|
217
|
+
if (!response.result.ok) return
|
|
218
|
+
controller.setExecutionOptions({
|
|
219
|
+
presets: response.result.value.presets.map(preset => ({
|
|
220
|
+
id: preset.id,
|
|
221
|
+
name: preset.name,
|
|
222
|
+
description: preset.description,
|
|
223
|
+
broken: preset.broken,
|
|
224
|
+
isDefault: preset.isDefault,
|
|
225
|
+
})),
|
|
226
|
+
})
|
|
227
|
+
} catch (error) {
|
|
228
|
+
// A failed roster read leaves the previous options in place; the
|
|
229
|
+
// picker stays usable and the next reconnect retries the read.
|
|
230
|
+
console.error('[dsh-task-board] agent preset roster read failed', error)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
void pushPresetOptions()
|
|
234
|
+
disposers.push(ctx.on('connection/reset', () => { void pushPresetOptions() }))
|
|
166
235
|
try {
|
|
167
236
|
disposers.push(mountSidebarEntry(controller))
|
|
168
237
|
disposers.push(mountBoard(controller))
|
package/src/client/locales.ts
CHANGED
|
@@ -14,6 +14,10 @@ export const zh = {
|
|
|
14
14
|
'board.search': '筛选任务…',
|
|
15
15
|
'board.empty': '这个状态还没有任务',
|
|
16
16
|
'board.filterAll': '全部',
|
|
17
|
+
'board.archive': '归档',
|
|
18
|
+
'board.archiveView': '归档 ({count})',
|
|
19
|
+
'board.backToBoard': '返回看板',
|
|
20
|
+
'archive.empty': '没有已归档的任务',
|
|
17
21
|
'board.status': '状态',
|
|
18
22
|
'board.status.backlog': '待规划',
|
|
19
23
|
'board.status.todo': '待办',
|
|
@@ -41,6 +45,9 @@ export const zh = {
|
|
|
41
45
|
'detail.run': '执行',
|
|
42
46
|
'detail.rerun': '重新执行',
|
|
43
47
|
'detail.delete': '删除',
|
|
48
|
+
'detail.archive': '归档',
|
|
49
|
+
'detail.restore': '恢复',
|
|
50
|
+
'detail.archivedAt': '已归档 · {time}',
|
|
44
51
|
'detail.viewSession': '查看会话',
|
|
45
52
|
'detail.noSession': '暂无会话',
|
|
46
53
|
'detail.executionStarted': '已启动',
|
|
@@ -73,6 +80,20 @@ export const zh = {
|
|
|
73
80
|
'detail.schedule.notScheduled': '尚未排程',
|
|
74
81
|
'detail.schedule.dueSoon': '即将运行',
|
|
75
82
|
'card.scheduled': '定时',
|
|
83
|
+
'new.workspace': '工作区',
|
|
84
|
+
'new.mode': '模式',
|
|
85
|
+
'new.permission': '权限',
|
|
86
|
+
'exec.workspace.recent': '最近使用(默认)',
|
|
87
|
+
'exec.mode.default': '部署默认',
|
|
88
|
+
'exec.mode.defaultSuffix': '(默认)',
|
|
89
|
+
'exec.mode.brokenSuffix': '(不可用)',
|
|
90
|
+
'exec.mode.removed': '(已移除)',
|
|
91
|
+
'exec.permission.default': '会话默认',
|
|
92
|
+
'exec.permission.read-only': '只读',
|
|
93
|
+
'exec.permission.workspace-write': '工作区可写',
|
|
94
|
+
'exec.permission.danger-full-access': '完全访问',
|
|
95
|
+
'detail.executionSettings': '执行设置',
|
|
96
|
+
'exec.hint': '执行时生效:工作区决定执行会话落在哪个工作区;模式决定会话的 agent 预设;权限经 /permission 命令应用到会话。留空则使用运行时默认。',
|
|
76
97
|
// 插件设置卡片(settings.plugin.item 席位)。
|
|
77
98
|
'settings.title': '任务看板',
|
|
78
99
|
'settings.description': '控制看板在 agent 系统提示中的播报行为。',
|
|
@@ -106,6 +127,10 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
106
127
|
'board.search': 'Filter tasks…',
|
|
107
128
|
'board.empty': 'No tasks in this column',
|
|
108
129
|
'board.filterAll': 'All',
|
|
130
|
+
'board.archive': 'Archive',
|
|
131
|
+
'board.archiveView': 'Archived ({count})',
|
|
132
|
+
'board.backToBoard': 'Back to board',
|
|
133
|
+
'archive.empty': 'No archived tasks',
|
|
109
134
|
'board.status': 'Status',
|
|
110
135
|
'board.status.backlog': 'Backlog',
|
|
111
136
|
'board.status.todo': 'To Do',
|
|
@@ -133,6 +158,9 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
133
158
|
'detail.run': 'Run',
|
|
134
159
|
'detail.rerun': 'Run Again',
|
|
135
160
|
'detail.delete': 'Delete',
|
|
161
|
+
'detail.archive': 'Archive',
|
|
162
|
+
'detail.restore': 'Restore',
|
|
163
|
+
'detail.archivedAt': 'Archived · {time}',
|
|
136
164
|
'detail.viewSession': 'View Session',
|
|
137
165
|
'detail.noSession': 'No session',
|
|
138
166
|
'detail.executionStarted': 'Started',
|
|
@@ -165,6 +193,20 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
165
193
|
'detail.schedule.notScheduled': 'Not scheduled yet',
|
|
166
194
|
'detail.schedule.dueSoon': 'Due soon',
|
|
167
195
|
'card.scheduled': 'scheduled',
|
|
196
|
+
'new.workspace': 'Workspace',
|
|
197
|
+
'new.mode': 'Mode',
|
|
198
|
+
'new.permission': 'Permission',
|
|
199
|
+
'exec.workspace.recent': 'Most recent (default)',
|
|
200
|
+
'exec.mode.default': 'Deployment default',
|
|
201
|
+
'exec.mode.defaultSuffix': ' (default)',
|
|
202
|
+
'exec.mode.brokenSuffix': ' (unavailable)',
|
|
203
|
+
'exec.mode.removed': ' (removed)',
|
|
204
|
+
'exec.permission.default': 'Session default',
|
|
205
|
+
'exec.permission.read-only': 'Read-only',
|
|
206
|
+
'exec.permission.workspace-write': 'Workspace Write',
|
|
207
|
+
'exec.permission.danger-full-access': 'Full Access',
|
|
208
|
+
'detail.executionSettings': 'Execution Settings',
|
|
209
|
+
'exec.hint': 'Applied when the task runs: the workspace decides where the execution session lands; the mode composes the session\'s agent preset; the permission is applied through the /permission command. Blank = runtime default.',
|
|
168
210
|
// Plugin settings card (the `settings.plugin.item` seat).
|
|
169
211
|
'settings.title': 'Task Board',
|
|
170
212
|
'settings.description': 'How the board announces itself in each agent system prompt.',
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared scheduled-run presets for the schedule editors (the new-task dialog
|
|
3
|
+
* and the task detail panel): cron expression → locale label.
|
|
4
|
+
*/
|
|
5
|
+
import type { TaskBoardKey } from './locales.ts'
|
|
6
|
+
|
|
7
|
+
/** Common scheduled-run presets (cron → locale label). */
|
|
8
|
+
export const SCHEDULE_PRESETS: ReadonlyArray<{ cron: string; label: TaskBoardKey }> = [
|
|
9
|
+
{ cron: '0 9 * * *', label: 'detail.schedule.preset.daily9' },
|
|
10
|
+
{ cron: '0 * * * *', label: 'detail.schedule.preset.hourly' },
|
|
11
|
+
{ cron: '*/10 * * * *', label: 'detail.schedule.preset.tenMin' },
|
|
12
|
+
{ cron: '0 9 * * 1', label: 'detail.schedule.preset.weeklyMon9' },
|
|
13
|
+
]
|