@linxin666/dsh-client-ui-task-board 0.4.4 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +59 -20
- package/README.zh.md +59 -20
- package/lib/client.js +6208 -624
- package/lib/client.js.map +1 -1
- package/lib/index.js +4774 -493
- package/lib/types/client/PluginSettingsCard.d.ts +7 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +31 -3
- package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
- package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
- package/lib/types/client/board/EditTaskModal.d.ts +5 -2
- package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
- package/lib/types/client/board/NewTaskModal.d.ts +4 -1
- package/lib/types/client/board/TagManagerModal.d.ts +26 -0
- package/lib/types/client/board/TaskBoard.d.ts +10 -1
- package/lib/types/client/board/TaskCard.d.ts +14 -1
- package/lib/types/client/board/TaskDetail.d.ts +3 -1
- package/lib/types/client/board/TaskForm.d.ts +4 -1
- package/lib/types/client/board/VerificationReport.d.ts +23 -0
- package/lib/types/client/board/card-view.d.ts +113 -0
- package/lib/types/client/board/icons.d.ts +32 -0
- package/lib/types/client/board/overlay.d.ts +69 -0
- package/lib/types/client/board/status-key.d.ts +15 -0
- package/lib/types/client/board/task-markdown.d.ts +19 -0
- package/lib/types/client/index.d.ts +23 -5
- package/lib/types/client/locales.d.ts +168 -1
- package/lib/types/client/native-panel.d.ts +6 -20
- package/lib/types/client/plugin-card-seat.d.ts +5 -0
- package/lib/types/client/schedule-zone.d.ts +50 -0
- package/lib/types/client/seats.d.ts +18 -0
- package/lib/types/client/service.d.ts +32 -0
- package/lib/types/client/settings-form.d.ts +16 -0
- package/lib/types/core/controller.d.ts +46 -11
- package/lib/types/core/extension.d.ts +283 -0
- package/lib/types/core/handover.d.ts +13 -1
- package/lib/types/core/issue-form.d.ts +16 -0
- package/lib/types/core/poll-cadence.d.ts +31 -0
- package/lib/types/core/tasks.d.ts +228 -17
- package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
- package/lib/types/core/use-cases/task-tag.d.ts +48 -0
- package/lib/types/core/use-cases/task-update.d.ts +5 -3
- package/lib/types/core/verification-budget.d.ts +52 -0
- package/lib/types/core/verification.d.ts +753 -0
- package/lib/types/core/workspace-target.d.ts +41 -0
- package/lib/types/host/agent-tools.d.ts +1 -1
- package/lib/types/host/extension-registry.d.ts +111 -0
- package/lib/types/host/llm-dispatch.d.ts +63 -0
- package/lib/types/host/verification-gate.d.ts +95 -0
- package/lib/types/host/verification-runner.d.ts +139 -0
- package/lib/types/host/workspace-evidence.d.ts +53 -0
- package/lib/types/host-ledger.d.ts +133 -14
- package/lib/types/host-runner.d.ts +55 -10
- package/lib/types/host-service.d.ts +205 -11
- package/lib/types/index.d.ts +134 -26
- package/lib/types/protocol.d.ts +92 -10
- package/lib/types/tool-surface.d.ts +71 -0
- package/package.json +28 -31
- package/src/client/PluginSettingsCard.tsx +9 -0
- package/src/client/TaskBoardSettingsCard.tsx +378 -66
- package/src/client/board/ClampedMarkdown.tsx +69 -0
- package/src/client/board/ConfirmDialog.tsx +7 -3
- package/src/client/board/EditTaskModal.tsx +5 -2
- package/src/client/board/LinkSubtaskModal.tsx +12 -7
- package/src/client/board/NewTaskModal.tsx +168 -21
- package/src/client/board/TagManagerModal.tsx +235 -0
- package/src/client/board/TaskBoard.tsx +259 -103
- package/src/client/board/TaskCard.tsx +214 -81
- package/src/client/board/TaskDetail.tsx +289 -43
- package/src/client/board/TaskForm.tsx +13 -3
- package/src/client/board/VerificationReport.tsx +322 -0
- package/src/client/board/card-view.ts +220 -0
- package/src/client/board/icons.tsx +89 -0
- package/src/client/board/overlay.tsx +189 -0
- package/src/client/board/status-key.ts +30 -0
- package/src/client/board/task-markdown.module.css +137 -0
- package/src/client/board/task-markdown.tsx +106 -0
- package/src/client/board-settings.module.css +66 -0
- package/src/client/board.module.css +488 -63
- package/src/client/index.ts +82 -51
- package/src/client/locales.ts +343 -6
- package/src/client/native-panel.tsx +18 -2
- package/src/client/plugin-card-seat.ts +7 -0
- package/src/client/schedule-zone.ts +100 -2
- package/src/client/seats.tsx +58 -0
- package/src/client/service.ts +94 -0
- package/src/client/settings-card.module.css +31 -5
- package/src/client/settings-form.ts +27 -1
- package/src/core/controller.ts +105 -10
- package/src/core/extension.ts +376 -0
- package/src/core/freeze-snapshot.ts +2 -2
- package/src/core/handover.ts +16 -1
- package/src/core/issue-form.ts +19 -0
- package/src/core/poll-cadence.ts +41 -0
- package/src/core/store.ts +80 -15
- package/src/core/tasks.ts +293 -45
- package/src/core/use-cases/task-create.ts +30 -9
- package/src/core/use-cases/task-parent.ts +3 -2
- package/src/core/use-cases/task-schedule.ts +190 -25
- package/src/core/use-cases/task-tag.ts +110 -0
- package/src/core/use-cases/task-update.ts +13 -7
- package/src/core/verification-budget.ts +115 -0
- package/src/core/verification.ts +1594 -0
- package/src/core/workspace-target.ts +59 -0
- package/src/host/agent-tools.ts +125 -38
- package/src/host/extension-registry.ts +509 -0
- package/src/host/llm-dispatch.ts +87 -0
- package/src/host/verification-gate.ts +487 -0
- package/src/host/verification-runner.ts +702 -0
- package/src/host/workspace-evidence.ts +176 -0
- package/src/host-ai.ts +15 -9
- package/src/host-ledger.ts +468 -71
- package/src/host-routes.ts +15 -2
- package/src/host-runner.ts +106 -24
- package/src/host-service.ts +546 -40
- package/src/index.ts +305 -21
- package/src/protocol.ts +226 -28
- package/src/tool-surface.ts +95 -0
- package/lib/invariant.js +0 -6
- package/lib/types/invariant.d.ts +0 -3
- package/src/invariant.ts +0 -4
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* the Host schedules with (`core/schedule.ts`), so the preview a user sees is
|
|
8
8
|
* computed by the code that will actually arm the rule.
|
|
9
9
|
*/
|
|
10
|
-
import { isValidTimeZone } from '../core/schedule.ts'
|
|
11
|
-
import {
|
|
10
|
+
import { isValidTimeZone, partsInZone, resolveHostTimeZone, zonedEpoch } from '../core/schedule.ts'
|
|
11
|
+
import { scheduleRunBudget, type ScheduleRule, type ScheduleStopReason } from '../core/tasks.ts'
|
|
12
|
+
import { t, type TaskBoardKey } from './locales.ts'
|
|
12
13
|
|
|
13
14
|
/** One selectable zone, in the order the picker shows it. */
|
|
14
15
|
export interface ZoneChoice {
|
|
@@ -112,6 +113,103 @@ export function relativeTimeLabel(targetMs: number, now: number): string {
|
|
|
112
113
|
return `${durationParts(delta)}`
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
/** Zero-pad a calendar/clock field for a `datetime-local` value. */
|
|
117
|
+
function pad(value: number): string {
|
|
118
|
+
return String(value).padStart(2, '0')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The `datetime-local` input value for an instant, read in an explicit zone:
|
|
123
|
+
* `YYYY-MM-DDTHH:mm`. Uses the shared engine's zone reader, so the editor
|
|
124
|
+
* shows the same wall clock the Host schedules by.
|
|
125
|
+
* @param epochMs - the instant to render.
|
|
126
|
+
* @param timeZone - zone to read it in; absent uses the Host zone.
|
|
127
|
+
* @returns the input value.
|
|
128
|
+
*/
|
|
129
|
+
export function zonedInputValue(epochMs: number, timeZone?: string): string {
|
|
130
|
+
const parts = partsInZone(usableZone(timeZone), epochMs)
|
|
131
|
+
return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}T${pad(parts.hour)}:${pad(parts.minute)}`
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Parse a `datetime-local` input value back to an instant, read in an explicit
|
|
136
|
+
* zone. Returns undefined when the text is not a whole calendar wall clock or
|
|
137
|
+
* names one the zone skips (a spring-forward gap), so the editor can refuse an
|
|
138
|
+
* unusable instant instead of silently moving it.
|
|
139
|
+
* @param value - the input text.
|
|
140
|
+
* @param timeZone - zone to interpret it in; absent uses the Host zone.
|
|
141
|
+
* @returns the instant, or undefined when it is not a usable wall clock.
|
|
142
|
+
*/
|
|
143
|
+
export function parseZonedInput(value: string, timeZone?: string): number | undefined {
|
|
144
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value.trim())
|
|
145
|
+
if (match === null) return undefined
|
|
146
|
+
const [year, month, day, hour, minute] = match.slice(1).map(Number)
|
|
147
|
+
if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59) return undefined
|
|
148
|
+
return zonedEpoch(usableZone(timeZone), year, month, day, hour, minute)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The default instant a new one-shot editor opens on: the next whole hour,
|
|
153
|
+
* rendered in the zone the rule will be read in.
|
|
154
|
+
* @param now - reference instant.
|
|
155
|
+
* @param timeZone - zone the rule is read in.
|
|
156
|
+
* @returns the input value for a near-future default.
|
|
157
|
+
*/
|
|
158
|
+
export function defaultZonedInputValue(now: number, timeZone?: string): string {
|
|
159
|
+
const nextHour = Math.ceil(now / 3_600_000) * 3_600_000
|
|
160
|
+
return zonedInputValue(nextHour, timeZone)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The zone the input helpers resolve against: an explicit resolved zone, or the Host zone. */
|
|
164
|
+
function usableZone(timeZone: string | undefined): string {
|
|
165
|
+
return timeZone !== undefined && isValidTimeZone(timeZone) ? timeZone : resolveHostTimeZone()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The editor's run-cap option for a stored cap: the two named presets, the
|
|
170
|
+
* unlimited entry, or a custom value the editor shows in its own field.
|
|
171
|
+
* @param maxRuns - the stored cap, when any.
|
|
172
|
+
* @returns the select option value.
|
|
173
|
+
*/
|
|
174
|
+
export function runCapOption(maxRuns: number | undefined): string {
|
|
175
|
+
if (maxRuns === undefined) return 'unlimited'
|
|
176
|
+
if (maxRuns === 1) return '1'
|
|
177
|
+
if (maxRuns === 2) return '2'
|
|
178
|
+
return 'custom'
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The locale key describing why a rule stopped or skipped an occurrence. */
|
|
182
|
+
export function stopReasonKey(reason: ScheduleStopReason): TaskBoardKey {
|
|
183
|
+
return `detail.schedule.stop.${reason}` as TaskBoardKey
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The run-budget line: how many scheduled executions the Host opened and how
|
|
188
|
+
* many remain. Derived from the counter and the cap rather than stored, so the
|
|
189
|
+
* display cannot drift from the ledger.
|
|
190
|
+
* @param rule - the schedule rule.
|
|
191
|
+
* @returns localized wording.
|
|
192
|
+
*/
|
|
193
|
+
export function budgetLabel(rule: ScheduleRule): string {
|
|
194
|
+
const budget = scheduleRunBudget(rule)
|
|
195
|
+
const done = String(rule.runCount)
|
|
196
|
+
if (budget === undefined) return t('detail.schedule.runsUnlimited', { done })
|
|
197
|
+
const remaining = Math.max(budget - rule.runCount, 0)
|
|
198
|
+
return `${t('detail.schedule.runs', { done, total: String(budget) })} · ${t('detail.schedule.remaining', { count: String(remaining) })}`
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The line describing a rule that stopped itself, or undefined while it may still fire. */
|
|
202
|
+
export function endedLabel(rule: ScheduleRule): string | undefined {
|
|
203
|
+
if (rule.endedReason === undefined) return undefined
|
|
204
|
+
return t('detail.schedule.ended', { reason: t(stopReasonKey(rule.endedReason)) })
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The line describing the last skipped occurrence, or undefined when none. */
|
|
208
|
+
export function skippedLabel(rule: ScheduleRule): string | undefined {
|
|
209
|
+
if (rule.skippedReason === undefined) return undefined
|
|
210
|
+
return t('detail.schedule.skipped', { reason: t(stopReasonKey(rule.skippedReason)) })
|
|
211
|
+
}
|
|
212
|
+
|
|
115
213
|
/**
|
|
116
214
|
* The full next-run label: the absolute wall clock in the schedule's own zone,
|
|
117
215
|
* plus the relative distance in parentheses. Showing both matters because a
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The board's child seats, threaded from the slot registration that declares
|
|
3
|
+
* them down to the components that consume them.
|
|
4
|
+
*
|
|
5
|
+
* The framework hands the declaring component a `renderSlot` binding; the board
|
|
6
|
+
* wraps that binding in a React context so a provider's contribution can render
|
|
7
|
+
* deep inside TaskDetail, the card, or the settings card without those
|
|
8
|
+
* components knowing which providers exist.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-task-board/client/seats
|
|
11
|
+
*/
|
|
12
|
+
import { createContext, useContext } from 'react'
|
|
13
|
+
import type { ReactNode } from 'react'
|
|
14
|
+
import {
|
|
15
|
+
TASK_BOARD_CARD_DECORATION,
|
|
16
|
+
TASK_BOARD_DETAIL_SECTION,
|
|
17
|
+
TASK_BOARD_SETTINGS_SECTION,
|
|
18
|
+
type TaskBoardCardDecorationProps,
|
|
19
|
+
type TaskBoardDetailSectionProps,
|
|
20
|
+
type TaskBoardSettingsSectionProps,
|
|
21
|
+
} from '../core/extension.ts'
|
|
22
|
+
|
|
23
|
+
/** The three provider seats, adapted to plain render functions. */
|
|
24
|
+
export interface TaskBoardSeats {
|
|
25
|
+
detailSection: (props: TaskBoardDetailSectionProps) => ReactNode
|
|
26
|
+
settingsSection: (props: TaskBoardSettingsSectionProps) => ReactNode
|
|
27
|
+
cardDecoration: (props: TaskBoardCardDecorationProps) => ReactNode
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Nothing registered: every seat renders empty. */
|
|
31
|
+
export const EMPTY_TASK_BOARD_SEATS: TaskBoardSeats = {
|
|
32
|
+
detailSection: () => null,
|
|
33
|
+
settingsSection: () => null,
|
|
34
|
+
cardDecoration: () => null,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The framework `renderSlot` binding shape (key + owner props share). */
|
|
38
|
+
export type SlotRenderBinding = (key: string, owner: Record<string, unknown>) => ReactNode
|
|
39
|
+
|
|
40
|
+
/** Adapt one framework renderSlot binding to the board's seat interface. */
|
|
41
|
+
export function seatsFromRenderSlot(renderSlot: SlotRenderBinding | undefined): TaskBoardSeats {
|
|
42
|
+
if (renderSlot === undefined) return EMPTY_TASK_BOARD_SEATS
|
|
43
|
+
return {
|
|
44
|
+
detailSection: props => renderSlot(TASK_BOARD_DETAIL_SECTION, props as unknown as Record<string, unknown>),
|
|
45
|
+
settingsSection: props => renderSlot(TASK_BOARD_SETTINGS_SECTION, props as unknown as Record<string, unknown>),
|
|
46
|
+
cardDecoration: props => renderSlot(TASK_BOARD_CARD_DECORATION, props as unknown as Record<string, unknown>),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const TaskBoardSeatsContext = createContext<TaskBoardSeats>(EMPTY_TASK_BOARD_SEATS)
|
|
51
|
+
|
|
52
|
+
/** Provider the board's registered components wrap their subtree in. */
|
|
53
|
+
export const TaskBoardSeatsProvider = TaskBoardSeatsContext.Provider
|
|
54
|
+
|
|
55
|
+
/** Read the current seat set (empty when no board registration is above). */
|
|
56
|
+
export function useTaskBoardSeats(): TaskBoardSeats {
|
|
57
|
+
return useContext(TaskBoardSeatsContext)
|
|
58
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The board's browser-half extension service (`taskBoard`).
|
|
3
|
+
*
|
|
4
|
+
* It gives a provider's browser half the client capability face from the
|
|
5
|
+
* contract: dispatch over the board's same-origin action channel, card
|
|
6
|
+
* visibility predicates, and a subscription to the board mirror. The board
|
|
7
|
+
* controller is attached and detached as the master switch mounts and unmounts
|
|
8
|
+
* the UI, so the service can be provided before any controller exists.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-task-board/client/service
|
|
11
|
+
*/
|
|
12
|
+
import type { BoardController } from '../core/controller.ts'
|
|
13
|
+
import type {
|
|
14
|
+
TaskBoardClientFace,
|
|
15
|
+
TaskBoardClientMirror,
|
|
16
|
+
TaskBoardExtensionActionRequest,
|
|
17
|
+
TaskBoardVisibilityPredicate,
|
|
18
|
+
} from '../core/extension.ts'
|
|
19
|
+
import type { TaskRecord } from '../core/tasks.ts'
|
|
20
|
+
|
|
21
|
+
export class TaskBoardClientService implements TaskBoardClientFace {
|
|
22
|
+
private controller: BoardController | undefined
|
|
23
|
+
private enabled = false
|
|
24
|
+
private unsubscribeController: (() => void) | undefined
|
|
25
|
+
/** Predicates registered before the controller exists, replayed on attach. */
|
|
26
|
+
private readonly pendingVisibility: TaskBoardVisibilityPredicate[] = []
|
|
27
|
+
private readonly listeners = new Set<(mirror: TaskBoardClientMirror) => void>()
|
|
28
|
+
|
|
29
|
+
/** Bind the live board controller (called when the UI mounts). */
|
|
30
|
+
attach(controller: BoardController): void {
|
|
31
|
+
if (this.controller === controller) return
|
|
32
|
+
this.detach()
|
|
33
|
+
this.controller = controller
|
|
34
|
+
this.unsubscribeController = controller.subscribe(() => { this.notify() })
|
|
35
|
+
for (const predicate of this.pendingVisibility.splice(0)) controller.registerVisibility(predicate)
|
|
36
|
+
this.notify()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Release the current controller (called when the UI unmounts). */
|
|
40
|
+
detach(): void {
|
|
41
|
+
if (this.controller === undefined) return
|
|
42
|
+
this.unsubscribeController?.()
|
|
43
|
+
this.unsubscribeController = undefined
|
|
44
|
+
this.controller = undefined
|
|
45
|
+
this.notify()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Apply the board's master switch. */
|
|
49
|
+
setEnabled(enabled: boolean): void {
|
|
50
|
+
if (this.enabled === enabled) return
|
|
51
|
+
this.enabled = enabled
|
|
52
|
+
this.notify()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
dispatch(request: TaskBoardExtensionActionRequest): Promise<boolean> {
|
|
56
|
+
if (this.controller === undefined) return Promise.resolve(false)
|
|
57
|
+
return this.controller.dispatchExtension(request)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
registerVisibility(predicate: TaskBoardVisibilityPredicate): () => void {
|
|
61
|
+
if (this.controller !== undefined) return this.controller.registerVisibility(predicate)
|
|
62
|
+
this.pendingVisibility.push(predicate)
|
|
63
|
+
return () => {
|
|
64
|
+
const index = this.pendingVisibility.indexOf(predicate)
|
|
65
|
+
if (index !== -1) this.pendingVisibility.splice(index, 1)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
subscribe(callback: (mirror: TaskBoardClientMirror) => void): () => void {
|
|
70
|
+
this.listeners.add(callback)
|
|
71
|
+
return () => { this.listeners.delete(callback) }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
snapshot(): TaskBoardClientMirror {
|
|
75
|
+
const state = this.controller?.getSnapshot()
|
|
76
|
+
return {
|
|
77
|
+
enabled: this.enabled,
|
|
78
|
+
tasks: (state?.tasks ?? []) as readonly TaskRecord[],
|
|
79
|
+
extensions: state?.extensions ?? {},
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private notify(): void {
|
|
84
|
+
const mirror = this.snapshot()
|
|
85
|
+
for (const listener of [...this.listeners]) {
|
|
86
|
+
try {
|
|
87
|
+
listener(mirror)
|
|
88
|
+
} catch (error) {
|
|
89
|
+
// A provider's mirror callback must never break the board.
|
|
90
|
+
console.error('[dsh-task-board] extension mirror callback failed', error)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
border: 0;
|
|
37
37
|
border-radius: 12px;
|
|
38
38
|
align-items: center;
|
|
39
|
-
gap:
|
|
40
|
-
padding:
|
|
39
|
+
gap: 13px;
|
|
40
|
+
padding: 16px;
|
|
41
41
|
display: flex;
|
|
42
42
|
}
|
|
43
43
|
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
box-sizing: border-box;
|
|
52
52
|
border-radius: 12px;
|
|
53
53
|
align-items: center;
|
|
54
|
-
gap:
|
|
55
|
-
padding:
|
|
54
|
+
gap: 13px;
|
|
55
|
+
padding: 16px;
|
|
56
56
|
display: flex;
|
|
57
57
|
}
|
|
58
58
|
|
|
@@ -64,6 +64,25 @@
|
|
|
64
64
|
display: flex;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/* Optional leading plugin mark: a quiet tile that takes the ink color on
|
|
68
|
+
hover and while the card is open, so the whole row reads as one control. */
|
|
69
|
+
.mark {
|
|
70
|
+
flex: none;
|
|
71
|
+
display: grid;
|
|
72
|
+
place-items: center;
|
|
73
|
+
width: 34px;
|
|
74
|
+
height: 34px;
|
|
75
|
+
border-radius: 10px;
|
|
76
|
+
background: var(--dsw-alias-bg-module-platform);
|
|
77
|
+
color: var(--dsw-alias-label-secondary);
|
|
78
|
+
transition: color 0.16s;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.header:hover .mark,
|
|
82
|
+
.cardOpen .mark {
|
|
83
|
+
color: var(--dsw-alias-label-primary);
|
|
84
|
+
}
|
|
85
|
+
|
|
67
86
|
.name {
|
|
68
87
|
color: var(--dsw-alias-label-primary);
|
|
69
88
|
font-size: 15px;
|
|
@@ -95,7 +114,13 @@
|
|
|
95
114
|
.chevron {
|
|
96
115
|
color: var(--dsw-alias-label-tertiary);
|
|
97
116
|
flex: none;
|
|
98
|
-
|
|
117
|
+
width: 16px;
|
|
118
|
+
height: 16px;
|
|
119
|
+
transition: transform 0.16s, color 0.16s;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.header:hover .chevron {
|
|
123
|
+
color: var(--dsw-alias-label-secondary);
|
|
99
124
|
}
|
|
100
125
|
|
|
101
126
|
.chevronOpen {
|
|
@@ -406,6 +431,7 @@
|
|
|
406
431
|
@media (prefers-reduced-motion: reduce) {
|
|
407
432
|
.card,
|
|
408
433
|
.header,
|
|
434
|
+
.mark,
|
|
409
435
|
.chevron,
|
|
410
436
|
.chevronOpen,
|
|
411
437
|
.discard,
|
|
@@ -215,6 +215,8 @@ export class CardForm<T> {
|
|
|
215
215
|
private readonly disposeForm: () => void
|
|
216
216
|
private disposed = false
|
|
217
217
|
private saving = false
|
|
218
|
+
/** A save asked for while one was in flight; it runs once the first settles (#1754). */
|
|
219
|
+
private saveQueued = false
|
|
218
220
|
private failed = false
|
|
219
221
|
private failedReason: string | undefined
|
|
220
222
|
|
|
@@ -283,7 +285,7 @@ export class CardForm<T> {
|
|
|
283
285
|
resetField: (field) => {
|
|
284
286
|
this.stage(field, { text: this.specOf(field).format(this.baseValue(field)), clear: true })
|
|
285
287
|
},
|
|
286
|
-
save: () => { void this.
|
|
288
|
+
save: () => { void this.requestSave() },
|
|
287
289
|
discard: () => {
|
|
288
290
|
if (this.staged.size === 0 && !this.failed) return
|
|
289
291
|
this.staged.clear()
|
|
@@ -310,6 +312,30 @@ export class CardForm<T> {
|
|
|
310
312
|
* the user can correct them instead of retyping.
|
|
311
313
|
* @returns settlement after the mutation and the read-back.
|
|
312
314
|
*/
|
|
315
|
+
/**
|
|
316
|
+
* Run one save, and re-run it once if another was asked for while this one
|
|
317
|
+
* was still in flight.
|
|
318
|
+
*
|
|
319
|
+
* A save is a Host round trip that also drives the profile reconcile, and
|
|
320
|
+
* the Host runs that write inside one exclusive transaction. Answering a
|
|
321
|
+
* press that arrives mid-flight by returning immediately dropped the edit
|
|
322
|
+
* with no explanation, which is the "the save button stops working after a
|
|
323
|
+
* few rounds" report (#1754). Serializing instead means a save pressed while
|
|
324
|
+
* another is still settling runs against the settled state - which is what
|
|
325
|
+
* the operator meant by pressing it again.
|
|
326
|
+
* @returns settlement after the mutation and the read-back.
|
|
327
|
+
*/
|
|
328
|
+
async requestSave(): Promise<void> {
|
|
329
|
+
if (this.saving) {
|
|
330
|
+
this.saveQueued = true
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
await this.save()
|
|
334
|
+
if (!this.saveQueued) return
|
|
335
|
+
this.saveQueued = false
|
|
336
|
+
await this.requestSave()
|
|
337
|
+
}
|
|
338
|
+
|
|
313
339
|
async save(): Promise<void> {
|
|
314
340
|
const plan = this.plan()
|
|
315
341
|
const valid = plan.filter((item): item is PlannedWrite & { judge: () => boolean } => item.judge !== undefined)
|
package/src/core/controller.ts
CHANGED
|
@@ -12,13 +12,14 @@
|
|
|
12
12
|
* owns only the orchestration seam (state, persistence, notify, navigation).
|
|
13
13
|
*/
|
|
14
14
|
import type { TaskStore } from './store.ts'
|
|
15
|
-
import { withStatus, type NewTaskInput, type TaskRecord, type TaskStatus } from './tasks.ts'
|
|
15
|
+
import { hasOpenExecution, withStatus, type NewTaskInput, type TaskRecord, type TaskStatus } from './tasks.ts'
|
|
16
16
|
import { applyArchiveTask, applyRestoreTask } from './use-cases/task-archive.ts'
|
|
17
17
|
import { applyCreateTask } from './use-cases/task-create.ts'
|
|
18
18
|
import { applyDeleteTask } from './use-cases/task-delete.ts'
|
|
19
|
-
import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule } from './use-cases/task-schedule.ts'
|
|
19
|
+
import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule, type SetSchedulePatch } from './use-cases/task-schedule.ts'
|
|
20
20
|
import { resolveHostTimeZone } from './schedule.ts'
|
|
21
21
|
import { applySetParent } from './use-cases/task-parent.ts'
|
|
22
|
+
import { applyDeleteTag, applyRenameTag } from './use-cases/task-tag.ts'
|
|
22
23
|
import { applyUpdateTask, type TaskUpdatePatch } from './use-cases/task-update.ts'
|
|
23
24
|
import { DEFAULT_SUBTASK_DEPTH } from './subtask.ts'
|
|
24
25
|
import type {
|
|
@@ -28,6 +29,10 @@ import type {
|
|
|
28
29
|
TaskBoardParseRequest,
|
|
29
30
|
TaskBoardSnapshot,
|
|
30
31
|
} from '../protocol.ts'
|
|
32
|
+
import type {
|
|
33
|
+
TaskBoardExtensionActionRequest,
|
|
34
|
+
TaskBoardVisibilityPredicate,
|
|
35
|
+
} from './extension.ts'
|
|
31
36
|
|
|
32
37
|
export interface TaskBoardTransport {
|
|
33
38
|
bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>
|
|
@@ -136,7 +141,7 @@ export interface ExecutionOptionsSnapshot {
|
|
|
136
141
|
* (revision/scheduler/power); a full snapshot also carries the deployment
|
|
137
142
|
* constants the UI reads (session default permission, subtask depth).
|
|
138
143
|
*/
|
|
139
|
-
export type HostMirror = Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission' | 'maxSubtaskDepth' | 'teamRunAvailable'>
|
|
144
|
+
export type HostMirror = Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission' | 'maxSubtaskDepth' | 'teamRunAvailable' | 'goalRunEnabled' | 'extensions'>
|
|
140
145
|
|
|
141
146
|
/** Immutable controller snapshot for UI subscriptions. */
|
|
142
147
|
export interface ControllerSnapshot {
|
|
@@ -154,6 +159,15 @@ export interface ControllerSnapshot {
|
|
|
154
159
|
canParseTask?: boolean
|
|
155
160
|
transportError?: string
|
|
156
161
|
host?: HostMirror
|
|
162
|
+
/**
|
|
163
|
+
* Provider card-visibility predicates, in registration order. A task shows
|
|
164
|
+
* only while every predicate accepts it; providers hide their own off-board
|
|
165
|
+
* cards here instead of the board hard-coding a provider rule. Optional so a
|
|
166
|
+
* hand-built snapshot (tests, a shell-less composition) defaults to visible.
|
|
167
|
+
*/
|
|
168
|
+
visibility?: readonly TaskBoardVisibilityPredicate[]
|
|
169
|
+
/** Read-only summaries published by running extensions, keyed by extension id. */
|
|
170
|
+
extensions?: Readonly<Record<string, unknown>>
|
|
157
171
|
}
|
|
158
172
|
|
|
159
173
|
/** The selected task (resolved from the ledger), or undefined. */
|
|
@@ -201,6 +215,8 @@ export class BoardController {
|
|
|
201
215
|
private readonly taskQueues = new Map<string, Promise<void>>()
|
|
202
216
|
private transportError: string | undefined
|
|
203
217
|
private hostState: HostMirror | undefined
|
|
218
|
+
/** Provider visibility predicates (immutable array; replaced on change). */
|
|
219
|
+
private visibility: readonly TaskBoardVisibilityPredicate[] = []
|
|
204
220
|
private remoteSubscribed = false
|
|
205
221
|
private remoteInitialization: Promise<boolean> | undefined
|
|
206
222
|
|
|
@@ -251,9 +267,42 @@ export class BoardController {
|
|
|
251
267
|
...(typeof this.deps.transport?.parseDraft === 'function' ? { canParseTask: true } : {}),
|
|
252
268
|
...(this.transportError === undefined ? {} : { transportError: this.transportError }),
|
|
253
269
|
...(this.hostState === undefined ? {} : { host: this.hostState }),
|
|
270
|
+
visibility: this.visibility,
|
|
271
|
+
...(this.hostState?.extensions === undefined ? {} : { extensions: this.hostState.extensions }),
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Register one card-visibility predicate. The returned disposer removes it;
|
|
277
|
+
* both changes notify subscribers so the board re-renders.
|
|
278
|
+
*/
|
|
279
|
+
registerVisibility(predicate: TaskBoardVisibilityPredicate): () => void {
|
|
280
|
+
this.visibility = [...this.visibility, predicate]
|
|
281
|
+
this.notify()
|
|
282
|
+
return () => {
|
|
283
|
+
const next = this.visibility.filter(candidate => candidate !== predicate)
|
|
284
|
+
if (next.length === this.visibility.length) return
|
|
285
|
+
this.visibility = next
|
|
286
|
+
this.notify()
|
|
254
287
|
}
|
|
255
288
|
}
|
|
256
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Deliver one provider action through the board's same-origin channel. A
|
|
292
|
+
* board without a Host transport (the legacy in-memory path) refuses it.
|
|
293
|
+
*/
|
|
294
|
+
async dispatchExtension(request: TaskBoardExtensionActionRequest): Promise<boolean> {
|
|
295
|
+
if (this.deps.transport === undefined) return false
|
|
296
|
+
const action: TaskBoardAction = {
|
|
297
|
+
kind: 'extension-action',
|
|
298
|
+
extensionId: request.extensionId,
|
|
299
|
+
action: request.action,
|
|
300
|
+
...(request.taskId === undefined ? {} : { taskId: request.taskId }),
|
|
301
|
+
...(request.payload === undefined ? {} : { payload: request.payload }),
|
|
302
|
+
}
|
|
303
|
+
return await this.commitRemote(action, request.taskId)
|
|
304
|
+
}
|
|
305
|
+
|
|
257
306
|
subscribe(fn: () => void): () => void {
|
|
258
307
|
this.listeners.add(fn)
|
|
259
308
|
return () => { this.listeners.delete(fn) }
|
|
@@ -507,18 +556,58 @@ export class BoardController {
|
|
|
507
556
|
return true
|
|
508
557
|
}
|
|
509
558
|
|
|
559
|
+
/**
|
|
560
|
+
* Rename one label across the whole ledger; a name already in use merges the
|
|
561
|
+
* two labels. Host-backed: the ledger owns the transition and confirms it, so
|
|
562
|
+
* the board's label row reflects whether the Host accepted the edit.
|
|
563
|
+
* @returns true when the edit was accepted by the authority.
|
|
564
|
+
*/
|
|
565
|
+
async renameTag(from: string, to: string): Promise<boolean> {
|
|
566
|
+
if (this.deps.transport !== undefined) {
|
|
567
|
+
return await this.commitRemote({ kind: 'rename-tag', from, to })
|
|
568
|
+
}
|
|
569
|
+
const result = applyRenameTag(this.tasks, from, to, this.now())
|
|
570
|
+
if (result.error !== undefined) return false
|
|
571
|
+
if (result.changed) {
|
|
572
|
+
this.tasks = [...result.tasks]
|
|
573
|
+
this.persistAndNotify()
|
|
574
|
+
}
|
|
575
|
+
return true
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Remove one label from every task that carries it, board and archive alike —
|
|
580
|
+
* the board's label row is drawn from the whole ledger, so a label left on an
|
|
581
|
+
* archived card would come straight back.
|
|
582
|
+
* @returns true when the edit was accepted by the authority.
|
|
583
|
+
*/
|
|
584
|
+
async deleteTag(name: string): Promise<boolean> {
|
|
585
|
+
if (this.deps.transport !== undefined) {
|
|
586
|
+
return await this.commitRemote({ kind: 'delete-tag', name })
|
|
587
|
+
}
|
|
588
|
+
const result = applyDeleteTag(this.tasks, name, this.now())
|
|
589
|
+
if (result.error !== undefined) return false
|
|
590
|
+
if (result.changed) {
|
|
591
|
+
this.tasks = [...result.tasks]
|
|
592
|
+
this.persistAndNotify()
|
|
593
|
+
}
|
|
594
|
+
return true
|
|
595
|
+
}
|
|
596
|
+
|
|
510
597
|
// --- scheduling ---------------------------------------------------------------
|
|
511
598
|
|
|
512
599
|
/**
|
|
513
|
-
* Update a task's schedule rule.
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
600
|
+
* Update a task's schedule rule. An unusable plan (blank or invalid cron, a
|
|
601
|
+
* one-shot with no future instant, a non-positive run cap) is rejected
|
|
602
|
+
* (returns false, state untouched). When the rule ends up enabled the next
|
|
603
|
+
* run instant is computed immediately — a one-shot's next instant IS its
|
|
604
|
+
* planned instant — and a disabled rule carries none. Delegates the domain
|
|
605
|
+
* transition to the schedule use case.
|
|
517
606
|
* @param id - the task to schedule.
|
|
518
607
|
* @param patch - fields to change (absent fields keep their current value).
|
|
519
|
-
* @returns true when applied, false when rejected (
|
|
608
|
+
* @returns true when applied, false when rejected (unknown task / unusable plan).
|
|
520
609
|
*/
|
|
521
|
-
setSchedule(id: string, patch:
|
|
610
|
+
setSchedule(id: string, patch: SetSchedulePatch): boolean {
|
|
522
611
|
// The Host zone is the fallback a rule with no stored zone follows: the
|
|
523
612
|
// mirrored snapshot carries it, and a controller without a Host mirror
|
|
524
613
|
// (pure client tests) falls back to the process zone.
|
|
@@ -572,7 +661,9 @@ export class BoardController {
|
|
|
572
661
|
*/
|
|
573
662
|
async runTask(id: string): Promise<boolean> {
|
|
574
663
|
const task = this.tasks.find(candidate => candidate.id === id)
|
|
575
|
-
|
|
664
|
+
// Only an open execution blocks a second launch; the 'running' column is a
|
|
665
|
+
// board statement and a card parked there by hand still runs on request.
|
|
666
|
+
if (task === undefined || task.archivedAt !== undefined || hasOpenExecution(task)) return false
|
|
576
667
|
if (this.deps.transport === undefined) return false
|
|
577
668
|
return await this.commitRemote({ kind: 'run', taskId: id }, id, currentOf(this.deps.sessions))
|
|
578
669
|
}
|
|
@@ -724,6 +815,8 @@ export class BoardController {
|
|
|
724
815
|
const sessionDefaultPermission = snapshot.sessionDefaultPermission ?? this.hostState?.sessionDefaultPermission
|
|
725
816
|
const maxSubtaskDepth = snapshot.maxSubtaskDepth ?? this.hostState?.maxSubtaskDepth
|
|
726
817
|
const teamRunAvailable = snapshot.teamRunAvailable ?? this.hostState?.teamRunAvailable
|
|
818
|
+
const goalRunEnabled = snapshot.goalRunEnabled ?? this.hostState?.goalRunEnabled
|
|
819
|
+
const extensions = snapshot.extensions ?? this.hostState?.extensions
|
|
727
820
|
return {
|
|
728
821
|
revision: snapshot.revision,
|
|
729
822
|
scheduler: snapshot.scheduler,
|
|
@@ -731,6 +824,8 @@ export class BoardController {
|
|
|
731
824
|
...(sessionDefaultPermission === undefined ? {} : { sessionDefaultPermission }),
|
|
732
825
|
...(maxSubtaskDepth === undefined ? {} : { maxSubtaskDepth }),
|
|
733
826
|
...(teamRunAvailable === undefined ? {} : { teamRunAvailable }),
|
|
827
|
+
...(goalRunEnabled === undefined ? {} : { goalRunEnabled }),
|
|
828
|
+
...(extensions === undefined ? {} : { extensions }),
|
|
734
829
|
}
|
|
735
830
|
}
|
|
736
831
|
|