dsh-taskboard 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -3
- package/lib/client.js +743 -142
- package/lib/host/execution.js +156 -28
- package/lib/host/execution.js.map +1 -1
- package/lib/host/routes.js +30 -3
- package/lib/host/routes.js.map +1 -1
- package/lib/host/scheduler.js +10 -1
- package/lib/host/scheduler.js.map +1 -1
- package/lib/host/store.js +31 -18
- package/lib/host/store.js.map +1 -1
- package/lib/host/tools.js +14 -4
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +19 -4
- package/lib/index.js.map +1 -1
- package/lib/shared/protocol.js +53 -2
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +1 -1
- package/src/client/api.ts +3 -0
- package/src/client/board/TaskBoard.tsx +95 -13
- package/src/client/board/TaskCard.tsx +6 -3
- package/src/client/board/TaskDetail.tsx +81 -7
- package/src/client/board/TaskFormModal.tsx +1 -1
- package/src/client/controller.ts +163 -4
- package/src/client/index.ts +10 -0
- package/src/client/session-jump.ts +93 -0
- package/src/client/sidebar-entry.ts +98 -1
- package/src/client/styles.ts +56 -2
- package/src/host/execution.ts +190 -16
- package/src/host/routes.ts +38 -11
- package/src/host/scheduler.ts +20 -4
- package/src/host/store.ts +34 -13
- package/src/host/tools.ts +30 -8
- package/src/index.ts +27 -3
- package/src/shared/protocol.ts +72 -3
- package/src/shared/version.ts +9 -0
package/src/client/controller.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { ChangeEvent, UpdateTaskBody, WorkspaceView } from '../shared/api.t
|
|
|
11
11
|
import type { TaskLedger, TaskRecord, Urgency } from '../shared/protocol.ts'
|
|
12
12
|
import { emptyLedger } from '../shared/protocol.ts'
|
|
13
13
|
import type { TaskboardClient } from './api.ts'
|
|
14
|
+
import type { SessionJumpResult } from './session-jump.ts'
|
|
14
15
|
|
|
15
16
|
/** View filters over the ledger. */
|
|
16
17
|
export interface BoardFilters {
|
|
@@ -20,12 +21,39 @@ export interface BoardFilters {
|
|
|
20
21
|
urgencies: Urgency[]
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
/** Column sort orders. */
|
|
25
|
+
export type SortBy = 'default' | 'updated' | 'urgency' | 'created'
|
|
26
|
+
|
|
27
|
+
/** localStorage key for persisted view state (filters + sort). */
|
|
28
|
+
const VIEW_KEY = 'dsh-taskboard-view-v1'
|
|
29
|
+
|
|
30
|
+
/** Load the persisted view state (never throws; fresh on any parse error). */
|
|
31
|
+
function loadView(): { workspaceId?: string; urgencies: Urgency[]; sortBy: SortBy } {
|
|
32
|
+
try {
|
|
33
|
+
const raw = localStorage.getItem(VIEW_KEY)
|
|
34
|
+
if (raw === null) return { urgencies: [], sortBy: 'default' }
|
|
35
|
+
const parsed = JSON.parse(raw) as { workspaceId?: string; urgencies?: Urgency[]; sortBy?: SortBy }
|
|
36
|
+
const sortBy = parsed.sortBy === 'updated' || parsed.sortBy === 'urgency' || parsed.sortBy === 'created' ? parsed.sortBy : 'default'
|
|
37
|
+
return {
|
|
38
|
+
workspaceId: typeof parsed.workspaceId === 'string' ? parsed.workspaceId : undefined,
|
|
39
|
+
urgencies: Array.isArray(parsed.urgencies) ? parsed.urgencies.filter(u => u === 'urgent' || u === 'normal' || u === 'relaxed') : [],
|
|
40
|
+
sortBy,
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
return { urgencies: [], sortBy: 'default' }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
23
47
|
/** Controller snapshot the views render. */
|
|
24
48
|
export interface ControllerState {
|
|
25
49
|
boardOpen: boolean
|
|
26
50
|
ledger: TaskLedger
|
|
27
51
|
workspaces: WorkspaceView[]
|
|
28
52
|
filters: BoardFilters
|
|
53
|
+
/** Free-text search over title/id (case-insensitive). */
|
|
54
|
+
search: string
|
|
55
|
+
/** Column sort order. */
|
|
56
|
+
sortBy: SortBy
|
|
29
57
|
/** Selected task id (detail view); undefined closes the detail. */
|
|
30
58
|
selectedId?: string
|
|
31
59
|
/** Task form modal visible (create when editingId is unset). */
|
|
@@ -38,13 +66,16 @@ export interface ControllerState {
|
|
|
38
66
|
error?: string
|
|
39
67
|
}
|
|
40
68
|
|
|
41
|
-
/** Instantiate the default state. */
|
|
69
|
+
/** Instantiate the default state (view state hydrated from localStorage). */
|
|
42
70
|
function initialState(): ControllerState {
|
|
71
|
+
const view = loadView()
|
|
43
72
|
return {
|
|
44
73
|
boardOpen: false,
|
|
45
74
|
ledger: emptyLedger(),
|
|
46
75
|
workspaces: [],
|
|
47
|
-
filters: { urgencies:
|
|
76
|
+
filters: { workspaceId: view.workspaceId, urgencies: view.urgencies },
|
|
77
|
+
search: '',
|
|
78
|
+
sortBy: view.sortBy,
|
|
48
79
|
composerOpen: false,
|
|
49
80
|
secondaryOpen: false,
|
|
50
81
|
}
|
|
@@ -59,6 +90,7 @@ export class BoardController {
|
|
|
59
90
|
private disposed = false
|
|
60
91
|
private disposeStream: (() => void) | undefined
|
|
61
92
|
private refreshInFlight: Promise<void> | undefined
|
|
93
|
+
private sessionJumper: ((sessionId: string) => Promise<SessionJumpResult>) | undefined
|
|
62
94
|
|
|
63
95
|
/** @param client - the route client. */
|
|
64
96
|
constructor(private readonly client: TaskboardClient) {}
|
|
@@ -137,17 +169,41 @@ export class BoardController {
|
|
|
137
169
|
/** Toggle the board. */
|
|
138
170
|
toggleBoard(): void { this.setState({ boardOpen: !this.state.boardOpen }) }
|
|
139
171
|
|
|
140
|
-
/** Set the project filter. */
|
|
172
|
+
/** Set the project filter (persisted). */
|
|
141
173
|
setWorkspaceFilter(workspaceId?: string): void {
|
|
142
174
|
this.setState({ filters: { ...this.state.filters, workspaceId } })
|
|
175
|
+
this.persistView()
|
|
143
176
|
}
|
|
144
177
|
|
|
145
|
-
/** Toggle one urgency chip. */
|
|
178
|
+
/** Toggle one urgency chip (persisted). */
|
|
146
179
|
toggleUrgency(urgency: Urgency): void {
|
|
147
180
|
const set = new Set(this.state.filters.urgencies)
|
|
148
181
|
if (set.has(urgency)) set.delete(urgency)
|
|
149
182
|
else set.add(urgency)
|
|
150
183
|
this.setState({ filters: { ...this.state.filters, urgencies: [...set] } })
|
|
184
|
+
this.persistView()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Set the free-text search (transient — not persisted). */
|
|
188
|
+
setSearch(search: string): void {
|
|
189
|
+
this.setState({ search })
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Set the column sort order (persisted). */
|
|
193
|
+
setSortBy(sortBy: SortBy): void {
|
|
194
|
+
this.setState({ sortBy })
|
|
195
|
+
this.persistView()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Write the current view state to localStorage (best effort). */
|
|
199
|
+
private persistView(): void {
|
|
200
|
+
try {
|
|
201
|
+
localStorage.setItem(VIEW_KEY, JSON.stringify({
|
|
202
|
+
workspaceId: this.state.filters.workspaceId,
|
|
203
|
+
urgencies: this.state.filters.urgencies,
|
|
204
|
+
sortBy: this.state.sortBy,
|
|
205
|
+
}))
|
|
206
|
+
} catch { /* storage unavailable (private mode etc.) — view just won't persist */ }
|
|
151
207
|
}
|
|
152
208
|
|
|
153
209
|
/** Select a task (open detail). */
|
|
@@ -165,6 +221,34 @@ export class BoardController {
|
|
|
165
221
|
/** Toggle the secondary tab. */
|
|
166
222
|
toggleSecondary(): void { this.setState({ secondaryOpen: !this.state.secondaryOpen }) }
|
|
167
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Install the session-jump bridge (built from the runtime sessions service
|
|
226
|
+
* by the client entry). Without it openSession reports 'unavailable'.
|
|
227
|
+
* @param jumper - the jump function from createSessionJumper.
|
|
228
|
+
*/
|
|
229
|
+
installSessionJumper(jumper: (sessionId: string) => Promise<SessionJumpResult>): void {
|
|
230
|
+
this.sessionJumper = jumper
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Jump to an execution's session (open it in the GUI). On success the board
|
|
235
|
+
* closes so the conversation shows; a deleted-or-archived session reports
|
|
236
|
+
* 'missing' for the caller to prompt about.
|
|
237
|
+
* @param sessionId - the execution's session id.
|
|
238
|
+
* @returns the jump outcome.
|
|
239
|
+
*/
|
|
240
|
+
async openSession(sessionId: string): Promise<SessionJumpResult> {
|
|
241
|
+
if (this.sessionJumper === undefined) return 'unavailable'
|
|
242
|
+
let result: SessionJumpResult
|
|
243
|
+
try {
|
|
244
|
+
result = await this.sessionJumper(sessionId)
|
|
245
|
+
} catch {
|
|
246
|
+
return 'unavailable'
|
|
247
|
+
}
|
|
248
|
+
if (result === 'opened') this.closeBoard()
|
|
249
|
+
return result
|
|
250
|
+
}
|
|
251
|
+
|
|
168
252
|
// ---------------------------------------------------------------- writes
|
|
169
253
|
/** Create a task (composer submit); returns the new task id, undefined on failure. */
|
|
170
254
|
async create(body: Parameters<TaskboardClient['create']>[0]): Promise<string | undefined> {
|
|
@@ -232,6 +316,16 @@ export class BoardController {
|
|
|
232
316
|
}
|
|
233
317
|
}
|
|
234
318
|
|
|
319
|
+
/** Cancel the running execution (stops the agent session; task returns to todo). */
|
|
320
|
+
async cancel(id: string): Promise<void> {
|
|
321
|
+
try {
|
|
322
|
+
await this.client.cancel(id)
|
|
323
|
+
await this.refresh()
|
|
324
|
+
} catch (error) {
|
|
325
|
+
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
235
329
|
/** Soft-delete (agent parity) then optional purge. */
|
|
236
330
|
async remove(id: string, ifVersion: number, purge: boolean): Promise<void> {
|
|
237
331
|
try {
|
|
@@ -242,4 +336,69 @@ export class BoardController {
|
|
|
242
336
|
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
243
337
|
}
|
|
244
338
|
}
|
|
339
|
+
|
|
340
|
+
/** Duplicate a task into a fresh todo card (same project/urgency/prompt/execution/model). */
|
|
341
|
+
async duplicate(task: TaskRecord): Promise<void> {
|
|
342
|
+
try {
|
|
343
|
+
await this.client.create({
|
|
344
|
+
title: `${task.title}(副本)`,
|
|
345
|
+
workspaceId: task.workspaceId,
|
|
346
|
+
urgency: task.urgency,
|
|
347
|
+
description: task.description.length > 0 ? task.description : undefined,
|
|
348
|
+
prompt: task.prompt.length > 0 ? task.prompt : undefined,
|
|
349
|
+
execution: task.execution.mode === 'scheduled' && task.execution.cron !== undefined
|
|
350
|
+
? { mode: 'scheduled', cron: task.execution.cron }
|
|
351
|
+
: { mode: 'claim' },
|
|
352
|
+
model: task.model,
|
|
353
|
+
})
|
|
354
|
+
await this.refresh()
|
|
355
|
+
} catch (error) {
|
|
356
|
+
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Download the whole ledger as a JSON backup file. */
|
|
361
|
+
exportJson(): void {
|
|
362
|
+
const stamp = new Date()
|
|
363
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
364
|
+
const name = `dsh-taskboard-${stamp.getFullYear()}${pad(stamp.getMonth() + 1)}${pad(stamp.getDate())}-${pad(stamp.getHours())}${pad(stamp.getMinutes())}.json`
|
|
365
|
+
const body = JSON.stringify(this.state.ledger, null, 2)
|
|
366
|
+
this.download(name, body, 'application/json')
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Download the task list as a CSV (BOM-prefixed for Excel + Chinese text). */
|
|
370
|
+
exportCsv(): void {
|
|
371
|
+
const esc = (v: unknown): string => {
|
|
372
|
+
const s = String(v ?? '')
|
|
373
|
+
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
|
374
|
+
}
|
|
375
|
+
const header = ['id', 'title', 'status', 'urgency', 'blocked', 'project', 'claimedBy', 'mode', 'cron', 'nextRunAt', 'model', 'createdAt', 'updatedAt', 'comments', 'executions']
|
|
376
|
+
const rows = this.state.ledger.tasks.map(t => [
|
|
377
|
+
t.id, t.title, t.status, t.urgency, t.blocked ? 'yes' : 'no', t.workspaceId,
|
|
378
|
+
t.claimedBy ?? '', t.execution.mode, t.execution.cron ?? '',
|
|
379
|
+
t.execution.nextRunAt !== undefined ? new Date(t.execution.nextRunAt).toISOString() : '',
|
|
380
|
+
t.model !== undefined ? `${t.model.provider}/${t.model.model}` : '',
|
|
381
|
+
new Date(t.createdAt).toISOString(), new Date(t.updatedAt).toISOString(),
|
|
382
|
+
t.comments.length, t.executions.length,
|
|
383
|
+
].map(esc).join(','))
|
|
384
|
+
const stamp = new Date()
|
|
385
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
386
|
+
const name = `dsh-taskboard-${stamp.getFullYear()}${pad(stamp.getMonth() + 1)}${pad(stamp.getDate())}.csv`
|
|
387
|
+
this.download(name, `\uFEFF${[header.join(','), ...rows].join('\r\n')}`, 'text/csv')
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Trigger a browser download (no-op when the DOM is unavailable). */
|
|
391
|
+
private download(filename: string, body: string, type: string): void {
|
|
392
|
+
try {
|
|
393
|
+
const blob = new Blob([body], { type })
|
|
394
|
+
const url = URL.createObjectURL(blob)
|
|
395
|
+
const a = document.createElement('a')
|
|
396
|
+
a.href = url
|
|
397
|
+
a.download = filename
|
|
398
|
+
a.click()
|
|
399
|
+
setTimeout(() => URL.revokeObjectURL(url), 5_000)
|
|
400
|
+
} catch (error) {
|
|
401
|
+
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
402
|
+
}
|
|
403
|
+
}
|
|
245
404
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { BoardController } from './controller.ts'
|
|
|
16
16
|
import { injectStyles } from './styles.ts'
|
|
17
17
|
import { mountSidebarEntry } from './sidebar-entry.ts'
|
|
18
18
|
import { mountBoard } from './board-mount.tsx'
|
|
19
|
+
import { createSessionJumper, type SessionsServiceFace, type WorkspacesServiceFace } from './session-jump.ts'
|
|
19
20
|
|
|
20
21
|
/** Client plugin name. */
|
|
21
22
|
export const name = 'dsh-taskboard/client'
|
|
@@ -65,6 +66,15 @@ export function apply(ctx: ClientContextFace): void {
|
|
|
65
66
|
}
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
// Session navigation for execution rows: resolved LAZILY on every jump —
|
|
70
|
+
// apply may run before the runtime provides the services, and a captured
|
|
71
|
+
// undefined would permanently disable the jump. On a platform without
|
|
72
|
+
// them the jump degrades to an 'unavailable' notice instead of failing.
|
|
73
|
+
controller.installSessionJumper(createSessionJumper({
|
|
74
|
+
getSessions: () => ctx.get?.('sessions') as SessionsServiceFace | undefined,
|
|
75
|
+
getWorkspaces: () => ctx.get?.('workspaces') as WorkspacesServiceFace | undefined,
|
|
76
|
+
}))
|
|
77
|
+
|
|
68
78
|
controller.start()
|
|
69
79
|
const disposers: Array<() => void> = []
|
|
70
80
|
try {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session jump: resolve an execution's session against the runtime's live
|
|
3
|
+
* session list and open it in the GUI. The runtime's `sessions` service owns
|
|
4
|
+
* the list mirror (`list.getSnapshot().byId`) and staging (`open`); the
|
|
5
|
+
* `workspaces` service carries the registry-global archive set.
|
|
6
|
+
*
|
|
7
|
+
* Outcomes are split so the UI can prompt precisely:
|
|
8
|
+
* - `opened` — staged and opened; the board closes over it.
|
|
9
|
+
* - `archived` — in the list but archived (hidden from the sidebar; its log
|
|
10
|
+
* survives, so it is distinguishable from deletion).
|
|
11
|
+
* - `missing` — absent from the live list: deleted.
|
|
12
|
+
* - `unavailable`— runtime session services absent (service timing / errors).
|
|
13
|
+
*
|
|
14
|
+
* Service resolution is deliberately LAZY (per click): plugin apply may run
|
|
15
|
+
* before the runtime provides `sessions`, and a once-captured undefined would
|
|
16
|
+
* permanently disable the jump. When the id misses, the list mirror may also
|
|
17
|
+
* simply lag (reconnect re-pull, late mount): one `refresh()` is awaited and
|
|
18
|
+
* the lookup retried before deciding.
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-taskboard/client/session-jump
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Outcome of one jump attempt. */
|
|
24
|
+
export type SessionJumpResult =
|
|
25
|
+
| 'opened'
|
|
26
|
+
| 'archived'
|
|
27
|
+
| 'missing'
|
|
28
|
+
| 'unavailable'
|
|
29
|
+
|
|
30
|
+
/** Narrow face of the runtime `sessions` service this module needs. */
|
|
31
|
+
export interface SessionsServiceFace {
|
|
32
|
+
/** Select a listed session as current (the window opens with it). */
|
|
33
|
+
open(id: string): void
|
|
34
|
+
/** Re-pull the session list baseline (mirror catch-up). */
|
|
35
|
+
refresh(): Promise<void>
|
|
36
|
+
/** Live session list snapshot. */
|
|
37
|
+
list: {
|
|
38
|
+
getSnapshot(): {
|
|
39
|
+
byId: Record<string, unknown>
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Narrow face of the runtime `workspaces` service this module needs. */
|
|
45
|
+
export interface WorkspacesServiceFace {
|
|
46
|
+
/** Workspace list snapshot (carries the archive set). */
|
|
47
|
+
list: {
|
|
48
|
+
getSnapshot(): {
|
|
49
|
+
archivedSessionIds: readonly string[]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Lazy per-click service resolution (services may appear after apply). */
|
|
55
|
+
export interface SessionServiceAccess {
|
|
56
|
+
/** The runtime sessions service, when currently provided. */
|
|
57
|
+
getSessions(): SessionsServiceFace | undefined
|
|
58
|
+
/** The runtime workspaces service, when currently provided (optional). */
|
|
59
|
+
getWorkspaces(): WorkspacesServiceFace | undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the jump function the controller installs.
|
|
64
|
+
* @param access - lazy service accessors, consulted on every jump.
|
|
65
|
+
* @returns the jump function: `(sessionId) => Promise<SessionJumpResult>`.
|
|
66
|
+
*/
|
|
67
|
+
export function createSessionJumper(access: SessionServiceAccess): (sessionId: string) => Promise<SessionJumpResult> {
|
|
68
|
+
const lookup = (sessions: SessionsServiceFace, workspaces: WorkspacesServiceFace | undefined, sessionId: string): 'openable' | 'archived' | 'absent' => {
|
|
69
|
+
const list = sessions.list.getSnapshot()
|
|
70
|
+
if (list.byId[sessionId] === undefined) return 'absent'
|
|
71
|
+
const archived = workspaces?.list.getSnapshot().archivedSessionIds.includes(sessionId) ?? false
|
|
72
|
+
return archived ? 'archived' : 'openable'
|
|
73
|
+
}
|
|
74
|
+
return async (sessionId: string): Promise<SessionJumpResult> => {
|
|
75
|
+
const sessions = access.getSessions()
|
|
76
|
+
if (sessions === undefined) return 'unavailable'
|
|
77
|
+
try {
|
|
78
|
+
let state = lookup(sessions, access.getWorkspaces(), sessionId)
|
|
79
|
+
if (state === 'absent') {
|
|
80
|
+
// Only the absent case can be a lagging mirror (reconnect re-pull,
|
|
81
|
+
// late mount); archived is a definitive verdict. One refresh, re-check.
|
|
82
|
+
try { await sessions.refresh() } catch { /* keep the pre-refresh verdict */ }
|
|
83
|
+
state = lookup(sessions, access.getWorkspaces(), sessionId)
|
|
84
|
+
}
|
|
85
|
+
if (state === 'archived') return 'archived'
|
|
86
|
+
if (state === 'absent') return 'missing'
|
|
87
|
+
sessions.open(sessionId)
|
|
88
|
+
return 'opened'
|
|
89
|
+
} catch {
|
|
90
|
+
return 'unavailable'
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -59,11 +59,106 @@ function createEntry(controller: BoardController): HTMLButtonElement {
|
|
|
59
59
|
entry.dataset.dshAtbEntry = ''
|
|
60
60
|
entry.className = 'dsh-atb-entry'
|
|
61
61
|
entry.setAttribute('aria-label', 'Agent 任务看板')
|
|
62
|
-
entry.innerHTML = `<span class="dsh-atb-entry-icon">${ICON}</span><span class="dsh-atb-entry-label">任务看板</span>`
|
|
62
|
+
entry.innerHTML = `<span class="dsh-atb-entry-icon">${ICON}</span><span class="dsh-atb-entry-label">任务看板</span><span class="dsh-atb-entry-stats"></span>`
|
|
63
63
|
entry.addEventListener('click', () => { controller.toggleBoard() })
|
|
64
64
|
return entry
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Live status counts shown at the right of the entry row:
|
|
69
|
+
* `[todo, in_progress, in_review]` (trashed tasks excluded).
|
|
70
|
+
*/
|
|
71
|
+
function entryStats(controller: BoardController): [number, number, number] {
|
|
72
|
+
let todo = 0
|
|
73
|
+
let inProgress = 0
|
|
74
|
+
let inReview = 0
|
|
75
|
+
for (const task of controller.getSnapshot().ledger.tasks) {
|
|
76
|
+
if (task.trashedAt !== undefined) continue
|
|
77
|
+
if (task.status === 'todo') todo++
|
|
78
|
+
else if (task.status === 'in_progress') inProgress++
|
|
79
|
+
else if (task.status === 'in_review') inReview++
|
|
80
|
+
}
|
|
81
|
+
return [todo, inProgress, inReview]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Set one rolling-number slot. Unchanged values no-op; changes animate the
|
|
86
|
+
* old value out and the new value in with a vertical scroll (up when the
|
|
87
|
+
* count grows, down when it shrinks). Plain DOM, no React.
|
|
88
|
+
*/
|
|
89
|
+
function setRollValue(slot: HTMLElement, value: number): void {
|
|
90
|
+
const text = String(value)
|
|
91
|
+
if (slot.dataset.value === text) return
|
|
92
|
+
const previous = slot.dataset.value
|
|
93
|
+
slot.dataset.value = text
|
|
94
|
+
slot.style.minWidth = `${text.length}ch`
|
|
95
|
+
// First render (no previous value): plain text, no animation.
|
|
96
|
+
if (previous === undefined) {
|
|
97
|
+
slot.textContent = text
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
// Finalize any in-flight animation before starting the next one.
|
|
101
|
+
if (slot.dataset.busy === '1') {
|
|
102
|
+
slot.dataset.busy = ''
|
|
103
|
+
slot.dataset.anim = ''
|
|
104
|
+
}
|
|
105
|
+
const oldEl = document.createElement('span')
|
|
106
|
+
oldEl.className = 'dsh-atb-rn'
|
|
107
|
+
oldEl.textContent = previous
|
|
108
|
+
const newEl = document.createElement('span')
|
|
109
|
+
newEl.className = 'dsh-atb-rn dsh-atb-rn-next'
|
|
110
|
+
newEl.textContent = text
|
|
111
|
+
slot.replaceChildren(oldEl, newEl)
|
|
112
|
+
// Grow → the strip scrolls up (new enters from below); shrink → down.
|
|
113
|
+
slot.dataset.dir = value > Number(previous) ? 'up' : 'down'
|
|
114
|
+
slot.dataset.busy = '1'
|
|
115
|
+
requestAnimationFrame(() => { slot.dataset.anim = '1' })
|
|
116
|
+
const finish = (): void => {
|
|
117
|
+
if (slot.dataset.busy !== '1') return
|
|
118
|
+
slot.dataset.busy = ''
|
|
119
|
+
slot.dataset.anim = ''
|
|
120
|
+
slot.textContent = slot.dataset.value ?? ''
|
|
121
|
+
}
|
|
122
|
+
slot.addEventListener('transitionend', finish, { once: true })
|
|
123
|
+
// Fallback when transitionend never fires (hidden tab, reduced motion).
|
|
124
|
+
setTimeout(finish, 400)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Wire the stats strip into the entry: builds the three slots and keeps them
|
|
129
|
+
* (plus the tooltip) in sync with every controller emit.
|
|
130
|
+
* @returns the update function (also called once immediately).
|
|
131
|
+
*/
|
|
132
|
+
function wireStats(entry: HTMLButtonElement, controller: BoardController): () => void {
|
|
133
|
+
const stats = entry.querySelector<HTMLElement>('.dsh-atb-entry-stats')
|
|
134
|
+
if (stats === null) return () => {}
|
|
135
|
+
// Slot order = [todo, in_progress, in_review]; each slot carries its status
|
|
136
|
+
// in data-stat so the stylesheet colors the digits (see .dsh-atb-roll).
|
|
137
|
+
const statKeys = ['todo', 'in_progress', 'in_review'] as const
|
|
138
|
+
const slots: HTMLElement[] = []
|
|
139
|
+
for (let i = 0; i < 3; i++) {
|
|
140
|
+
if (i > 0) {
|
|
141
|
+
const sep = document.createElement('span')
|
|
142
|
+
sep.className = 'dsh-atb-entry-sep'
|
|
143
|
+
sep.textContent = '|'
|
|
144
|
+
stats.append(sep)
|
|
145
|
+
}
|
|
146
|
+
const slot = document.createElement('span')
|
|
147
|
+
slot.className = 'dsh-atb-roll'
|
|
148
|
+
slot.dataset.stat = statKeys[i]
|
|
149
|
+
stats.append(slot)
|
|
150
|
+
slots.push(slot)
|
|
151
|
+
}
|
|
152
|
+
const update = (): void => {
|
|
153
|
+
const [todo, inProgress, inReview] = entryStats(controller)
|
|
154
|
+
setRollValue(slots[0]!, todo)
|
|
155
|
+
setRollValue(slots[1]!, inProgress)
|
|
156
|
+
setRollValue(slots[2]!, inReview)
|
|
157
|
+
stats.title = `待办 ${todo} | 进行中 ${inProgress} | 待验收 ${inReview}(待办|进行中|待验收)`
|
|
158
|
+
}
|
|
159
|
+
return update
|
|
160
|
+
}
|
|
161
|
+
|
|
67
162
|
/** Re-insert the entry after the New Session row (before the browser region). */
|
|
68
163
|
function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
|
|
69
164
|
const button = newSessionButton(root)
|
|
@@ -146,9 +241,11 @@ export function mountSidebarEntry(controller: BoardController): () => void {
|
|
|
146
241
|
// alone; the timer costs one cheap contains-check per tick once placed).
|
|
147
242
|
const retry = setInterval(() => { tryPlace() }, 2_000)
|
|
148
243
|
|
|
244
|
+
const syncStats = wireStats(entry, controller)
|
|
149
245
|
const syncActive = () => {
|
|
150
246
|
if (controller.getSnapshot().boardOpen) entry.dataset.active = 'true'
|
|
151
247
|
else delete entry.dataset.active
|
|
248
|
+
syncStats()
|
|
152
249
|
}
|
|
153
250
|
const unsubscribe = controller.subscribe(syncActive)
|
|
154
251
|
syncActive()
|
package/src/client/styles.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
/** The stylesheet text. */
|
|
11
11
|
export const STYLES = `
|
|
12
12
|
.dsh-atb-entry {
|
|
13
|
-
display: flex; align-items: center; gap: 8px;
|
|
13
|
+
display: flex; align-items: center; gap: 8px; position: relative;
|
|
14
14
|
width: calc(100% - 8px); margin: 2px 4px; padding: 6px 10px;
|
|
15
15
|
border: none; border-radius: 8px; background: transparent;
|
|
16
16
|
color: var(--dsw-text-secondary, inherit); font: inherit; font-size: 13px;
|
|
@@ -19,6 +19,35 @@ export const STYLES = `
|
|
|
19
19
|
.dsh-atb-entry:hover { background: var(--dsw-hover, rgba(128,128,128,.12)); color: var(--dsw-text-primary, inherit); }
|
|
20
20
|
.dsh-atb-entry[data-active="true"] { background: var(--dsw-active, rgba(128,128,128,.18)); color: var(--dsw-text-primary, inherit); font-weight: 500; }
|
|
21
21
|
.dsh-atb-entry svg { flex: none; }
|
|
22
|
+
/* Status strip on the entry row's right: todo|in_progress|in_review counts. */
|
|
23
|
+
.dsh-atb-entry-stats {
|
|
24
|
+
margin-left: auto; display: inline-flex; align-items: center; gap: 3px;
|
|
25
|
+
font-size: 11px; line-height: 1; color: var(--dsw-text-secondary, gray);
|
|
26
|
+
font-variant-numeric: tabular-nums; white-space: nowrap; cursor: help;
|
|
27
|
+
}
|
|
28
|
+
.dsh-atb-entry-sep { opacity: .5; }
|
|
29
|
+
/* Each rolling count wears its status color (todo blue | in_progress orange |
|
|
30
|
+
in_review purple); the separators stay in the strip's neutral gray. */
|
|
31
|
+
.dsh-atb-roll[data-stat="todo"] { color: #3e63dd; }
|
|
32
|
+
.dsh-atb-roll[data-stat="in_progress"] { color: #d9822b; }
|
|
33
|
+
.dsh-atb-roll[data-stat="in_review"] { color: #8e4ec6; }
|
|
34
|
+
/* One rolling number: fixed one-line window, overflow hidden. */
|
|
35
|
+
.dsh-atb-roll {
|
|
36
|
+
position: relative; display: inline-block; overflow: hidden;
|
|
37
|
+
height: 12px; min-width: 1ch; text-align: center; vertical-align: middle;
|
|
38
|
+
}
|
|
39
|
+
.dsh-atb-rn { display: block; height: 12px; line-height: 12px; text-align: center; }
|
|
40
|
+
/* The incoming value sits just outside the window (below for up-scroll). */
|
|
41
|
+
.dsh-atb-rn-next { position: absolute; left: 0; right: 0; top: 100%; }
|
|
42
|
+
.dsh-atb-roll[data-dir="down"] .dsh-atb-rn-next { top: auto; bottom: 100%; }
|
|
43
|
+
.dsh-atb-roll .dsh-atb-rn { transition: transform .3s cubic-bezier(.25, .1, .25, 1); }
|
|
44
|
+
.dsh-atb-roll[data-anim="1"][data-dir="up"] .dsh-atb-rn { transform: translateY(-100%); }
|
|
45
|
+
.dsh-atb-roll[data-anim="1"][data-dir="down"] .dsh-atb-rn { transform: translateY(100%); }
|
|
46
|
+
@media (prefers-reduced-motion: reduce) {
|
|
47
|
+
.dsh-atb-roll .dsh-atb-rn { transition: none; }
|
|
48
|
+
}
|
|
49
|
+
.dsh-atb-search { width: 130px; }
|
|
50
|
+
.dsh-atb-badge[data-kind="stale"] { background: rgba(217,130,43,.15); color: #d9822b; }
|
|
22
51
|
|
|
23
52
|
html[data-dsh-atb-active] [data-pane="conversation"] > *:not([data-dsh-atb-view]) { display: none !important; }
|
|
24
53
|
.dsh-atb-view { display: none; }
|
|
@@ -28,6 +57,16 @@ html[data-dsh-atb-active] .dsh-atb-view { display: flex; flex-direction: column;
|
|
|
28
57
|
.dsh-atb-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
29
58
|
.dsh-atb-title { font-size: 15px; font-weight: 600; margin: 0; }
|
|
30
59
|
.dsh-atb-count { font-size: 12px; color: var(--dsw-text-secondary, gray); }
|
|
60
|
+
.dsh-atb-ver {
|
|
61
|
+
font-size: 11px; color: var(--dsw-text-secondary, gray);
|
|
62
|
+
font-variant-numeric: tabular-nums; white-space: nowrap; cursor: pointer;
|
|
63
|
+
text-decoration: none;
|
|
64
|
+
padding: 1px 9px; border-radius: 999px;
|
|
65
|
+
background: var(--dsw-bg-inset, rgba(128,128,128,.1));
|
|
66
|
+
border: 1px solid var(--dsw-border, rgba(128,128,128,.22));
|
|
67
|
+
transition: border-color .12s ease, color .12s ease;
|
|
68
|
+
}
|
|
69
|
+
.dsh-atb-ver:hover { border-color: var(--dsw-border-strong, rgba(128,128,128,.6)); color: inherit; }
|
|
31
70
|
.dsh-atb-spacer { flex: 1; }
|
|
32
71
|
.dsh-atb-select, .dsh-atb-input {
|
|
33
72
|
font: inherit; font-size: 12.5px; padding: 5px 8px; border-radius: 7px;
|
|
@@ -48,6 +87,16 @@ html[data-dsh-atb-active] .dsh-atb-view { display: flex; flex-direction: column;
|
|
|
48
87
|
.dsh-atb-dot[data-urgency="urgent"] { background: #e5484d; }
|
|
49
88
|
.dsh-atb-dot[data-urgency="normal"] { background: #8e4ec6; }
|
|
50
89
|
.dsh-atb-dot[data-urgency="relaxed"] { background: #3e63dd; }
|
|
90
|
+
/* Status dots (column heads): one fixed color per lifecycle status, matching
|
|
91
|
+
the detail pane's status pills. Canceled/archived share the resting gray;
|
|
92
|
+
trashed (pending purge) keeps the red of the 待清除 badge. */
|
|
93
|
+
.dsh-atb-dot[data-status="backlog"] { background: #8a8f98; }
|
|
94
|
+
.dsh-atb-dot[data-status="todo"] { background: #3e63dd; }
|
|
95
|
+
.dsh-atb-dot[data-status="in_progress"] { background: #d9822b; }
|
|
96
|
+
.dsh-atb-dot[data-status="in_review"] { background: #8e4ec6; }
|
|
97
|
+
.dsh-atb-dot[data-status="done"] { background: #2ea043; }
|
|
98
|
+
.dsh-atb-dot[data-status="canceled"], .dsh-atb-dot[data-status="archived"] { background: #8a8f98; }
|
|
99
|
+
.dsh-atb-dot[data-status="trashed"] { background: #e5484d; }
|
|
51
100
|
|
|
52
101
|
.dsh-atb-btn {
|
|
53
102
|
font: inherit; font-size: 12.5px; padding: 5px 11px; border-radius: 7px; cursor: pointer;
|
|
@@ -178,6 +227,7 @@ html[data-dsh-atb-active] .dsh-atb-view { display: flex; flex-direction: column;
|
|
|
178
227
|
transition: filter .12s ease;
|
|
179
228
|
}
|
|
180
229
|
.dsh-atb-detail-run:hover { filter: brightness(1.1); }
|
|
230
|
+
.dsh-atb-detail-run[data-danger="true"] { background: rgba(229,72,77,.92); }
|
|
181
231
|
.dsh-atb-movebtns { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
182
232
|
.dsh-atb-movebtn {
|
|
183
233
|
font: inherit; font-size: 12px; padding: 4px 11px; border-radius: 999px; cursor: pointer;
|
|
@@ -256,7 +306,11 @@ html[data-dsh-atb-active] .dsh-atb-view { display: flex; flex-direction: column;
|
|
|
256
306
|
.dsh-atb-exec-outcome[data-outcome="running"] { background: rgba(217,130,43,.15); color: #d9822b; }
|
|
257
307
|
.dsh-atb-exec-outcome[data-outcome="cancelled"] { background: rgba(128,128,128,.15); color: var(--dsw-text-secondary, gray); }
|
|
258
308
|
.dsh-atb-exec-time { font-size: 11px; color: var(--dsw-text-secondary, gray); }
|
|
259
|
-
.dsh-atb-exec-session {
|
|
309
|
+
.dsh-atb-exec-session {
|
|
310
|
+
font: inherit; font-size: 11px; color: var(--dsw-text-secondary, gray);
|
|
311
|
+
background: none; border: none; padding: 0; cursor: pointer;
|
|
312
|
+
}
|
|
313
|
+
.dsh-atb-exec-session:hover { color: var(--dsw-alias-brand-primary, inherit); text-decoration: underline dotted; }
|
|
260
314
|
.dsh-atb-exec-error { flex-basis: 100%; font-size: 11px; color: #e5484d; word-break: break-all; }
|
|
261
315
|
|
|
262
316
|
.dsh-atb-dangerzone {
|