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.
@@ -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
  }
@@ -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()
@@ -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 { font-size: 11px; color: var(--dsw-text-secondary, gray); }
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 {