@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.
Files changed (37) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +11 -7
  3. package/README.zh.md +11 -7
  4. package/lib/client.js +821 -145
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1 -1
  7. package/lib/types/client/PluginSettingsCard.d.ts +11 -0
  8. package/lib/types/client/locales.d.ts +21 -0
  9. package/lib/types/client/schedule-presets.d.ts +10 -0
  10. package/lib/types/core/controller.d.ts +57 -1
  11. package/lib/types/core/execution.d.ts +65 -4
  12. package/lib/types/core/scheduler.d.ts +7 -0
  13. package/lib/types/core/store.d.ts +1 -1
  14. package/lib/types/core/tasks.d.ts +43 -0
  15. package/lib/types/core/use-cases/task-archive.d.ts +22 -0
  16. package/lib/types/core/use-cases/task-create.d.ts +1 -6
  17. package/lib/types/core/use-cases/task-update.d.ts +9 -4
  18. package/lib/types/index.d.ts +1 -1
  19. package/package.json +1 -1
  20. package/src/client/PluginSettingsCard.tsx +50 -47
  21. package/src/client/board/NewTaskModal.tsx +136 -3
  22. package/src/client/board/TaskBoard.tsx +48 -19
  23. package/src/client/board/TaskDetail.tsx +97 -8
  24. package/src/client/board.module.css +16 -0
  25. package/src/client/index.ts +71 -2
  26. package/src/client/locales.ts +42 -0
  27. package/src/client/schedule-presets.ts +13 -0
  28. package/src/client/settings-card.module.css +9 -0
  29. package/src/core/controller.ts +92 -2
  30. package/src/core/execution.ts +179 -12
  31. package/src/core/scheduler.ts +15 -0
  32. package/src/core/store.ts +16 -2
  33. package/src/core/tasks.ts +56 -0
  34. package/src/core/use-cases/task-archive.ts +48 -0
  35. package/src/core/use-cases/task-create.ts +12 -3
  36. package/src/core/use-cases/task-update.ts +34 -5
  37. 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 { t } from '../locales.ts'
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
- const task = controller.createTask({ title, description, prompt })
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 visible = snapshot.tasks.filter(task => matchesFilter(task, filter))
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
- {COLUMNS.map(column => {
85
- const tasks = visible.filter(task => task.status === column.status)
86
- return (
87
- <section key={column.status} className={css.column} data-status={column.status}>
88
- <header className={css.columnHeader}>
89
- <span className={css.statusDot} data-status={column.status} aria-hidden="true" />
90
- <h3 className={css.columnTitle}>{t(STATUS_KEY[column.status])}</h3>
91
- <span className={css.columnCount}>{tasks.length}</span>
92
- </header>
93
- <div className={css.cards}>
94
- {tasks.map(task => (
95
- <MemoTaskCard key={task.id} task={task} onOpen={openTask} />
96
- ))}
97
- {tasks.length === 0 && <div className={css.columnEmpty}>{t('board.empty')}</div>}
98
- </div>
99
- </section>
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
- /** Common scheduled-run presets (cron → locale label). */
62
- const SCHEDULE_PRESETS: ReadonlyArray<{ cron: string; label: TaskBoardKey }> = [
63
- { cron: '0 9 * * *', label: 'detail.schedule.preset.daily9' },
64
- { cron: '0 * * * *', label: 'detail.schedule.preset.hourly' },
65
- { cron: '*/10 * * * *', label: 'detail.schedule.preset.tenMin' },
66
- { cron: '0 9 * * 1', label: 'detail.schedule.preset.weeklyMon9' },
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;
@@ -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 => sessions.binding(id as SessionId),
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))
@@ -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
+ ]
@@ -42,6 +42,15 @@
42
42
  outline-offset: -2px;
43
43
  }
44
44
 
45
+ .headerStatic {
46
+ width: 100%;
47
+ border-radius: 12px;
48
+ align-items: center;
49
+ gap: 12px;
50
+ padding: 14px 16px;
51
+ display: flex;
52
+ }
53
+
45
54
  .headText {
46
55
  flex-direction: column;
47
56
  flex: 1;