@veewo/dsh-better-tasks 0.0.0-stage → 0.4.5

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/lib/client.js ADDED
@@ -0,0 +1,3176 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@veewo/dsh-better-tasks",
3
+ factory: requireModule => {
4
+ const EMPTY_QUEUE = Object.freeze({ schemaVersion: 1, revision: 0, sessionIds: Object.freeze([]), updatedAt: '' })
5
+
6
+ function validQueue(value) {
7
+ return value !== null && typeof value === 'object' && value.schemaVersion === 1 &&
8
+ Number.isSafeInteger(value.revision) && value.revision >= 0 && Array.isArray(value.sessionIds) &&
9
+ value.sessionIds.every(id => typeof id === 'string' && id !== '') &&
10
+ new Set(value.sessionIds).size === value.sessionIds.length && typeof value.updatedAt === 'string'
11
+ }
12
+ function ownedQueue(value) {
13
+ if (!validQueue(value)) throw new Error('better-tasks: Host returned an invalid task order')
14
+ return Object.freeze({ ...value, sessionIds: Object.freeze([...value.sessionIds]) })
15
+ }
16
+ function createClientSource(initial) {
17
+ let snapshot = initial
18
+ const listeners = new Set()
19
+ return { getSnapshot: () => snapshot, subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn) },
20
+ set(next) { snapshot = next; for (const fn of [...listeners]) fn() } }
21
+ }
22
+
23
+ /** Rank is metadata only: stale rank can never resurrect a native-unpinned Session. */
24
+ function projectNativeTaskQueue(rank, native) {
25
+ const archived = new Set(native?.archivedSessionIds ?? [])
26
+ const pins = [...new Set(native?.pinnedSessionIds ?? [])].filter(id => !archived.has(id))
27
+ const members = new Set(pins), ranked = new Set(rank.sessionIds)
28
+ return Object.freeze({ ...rank, sessionIds: Object.freeze([
29
+ ...pins.filter(id => !ranked.has(id)), ...rank.sessionIds.filter(id => members.has(id)),
30
+ ]) })
31
+ }
32
+ async function pinRequest(method, body, signal) {
33
+ const response = await fetch('/api/better-tasks/pins', { method, signal,
34
+ headers: body === undefined ? undefined : { 'content-type': 'application/json' },
35
+ body: body === undefined ? undefined : JSON.stringify(body) })
36
+ let payload
37
+ try { payload = await response.json() } catch { payload = {} }
38
+ return { response, payload }
39
+ }
40
+
41
+ /** Observe official membership; own only rank refresh, pending feedback and errors. */
42
+ function createTaskPinsClient({ request = pinRequest, schedule = setTimeout, cancel = clearTimeout } = {}) {
43
+ const source = createClientSource(Object.freeze({ phase: 'loading', queue: EMPTY_QUEUE,
44
+ pendingSessionIds: Object.freeze([]), error: null, writable: false, migration: null }))
45
+ const pending = new Map(), pinFlights = new Map()
46
+ const lifetime = new AbortController()
47
+ let rank = EMPTY_QUEUE, native, binding, disconnect, tail = Promise.resolve()
48
+ let started = false, disposed = false, generation = 0, loaded = false, writable = false, hasStableRank = false
49
+ let orderError = null, actionError = null, warnings = [], migration = null
50
+ let refreshPromise = null, refreshAgain = false, timer, polls = 0
51
+ const nativeReady = () => native?.phase === 'ready'
52
+ const publish = () => {
53
+ if (disposed) return
54
+ const nativeError = native?.error?.message ?? (native?.state === 'error' ? 'Native Pin service is unavailable' : null)
55
+ source.set(Object.freeze({ phase: nativeReady() ? 'ready' : 'loading',
56
+ queue: projectNativeTaskQueue(rank, native), pendingSessionIds: Object.freeze([...pending.keys()]),
57
+ error: actionError ?? orderError ?? nativeError ?? (warnings.length ? warnings.join('; ') : null),
58
+ writable: nativeReady() && loaded && writable, migration: migration?.status ?? null }))
59
+ }
60
+ const countPending = (id, delta) => {
61
+ const count = (pending.get(id) ?? 0) + delta
62
+ if (count > 0) pending.set(id, count); else pending.delete(id)
63
+ publish()
64
+ }
65
+ const enqueue = work => {
66
+ const next = tail.then(() => { if (disposed) throw new Error('Task pins are disposed'); return work() })
67
+ tail = next.catch(() => {})
68
+ return next
69
+ }
70
+ const stopTimer = () => { if (timer !== undefined) { cancel(timer); timer = undefined } }
71
+ const armMigrationRefresh = () => {
72
+ stopTimer()
73
+ const restoring = ['pending', 'initializing', 'running'].includes(migration?.status)
74
+ const saving = loaded && !writable && ['done', 'partial'].includes(migration?.status)
75
+ if (!started || disposed || !(restoring || saving)) { polls = 0; return }
76
+ if (polls >= 60) return // Native follow/focus still refresh; never poll indefinitely after a stuck migration.
77
+ polls++
78
+ timer = schedule(() => { timer = undefined; void refresh().catch(() => {}) }, 1000)
79
+ }
80
+ const adopt = payload => {
81
+ const incoming = ownedQueue(payload.queue)
82
+ const initializing = payload.migration?.status === 'initializing'
83
+ if (!initializing || !hasStableRank) rank = incoming
84
+ if (!initializing) hasStableRank = true
85
+ loaded = true; writable = payload.writable !== false
86
+ warnings = Array.isArray(payload.warnings) ? payload.warnings.filter(x => typeof x === 'string') : []
87
+ migration = payload.migration ?? null
88
+ orderError = null
89
+ publish(); armMigrationRefresh()
90
+ return source.getSnapshot().queue
91
+ }
92
+ const readOrder = async () => {
93
+ const token = generation
94
+ try {
95
+ const { response, payload } = await request('GET', undefined, lifetime.signal)
96
+ if (disposed || token !== generation) return source.getSnapshot().queue
97
+ if (!response.ok) throw new Error(payload.message ?? `Task order read failed (${response.status})`)
98
+ return adopt(payload)
99
+ } catch (error) {
100
+ if (!disposed && token === generation) { orderError = String(error?.message ?? error); writable = false; publish() }
101
+ throw error
102
+ }
103
+ }
104
+ const refresh = () => {
105
+ if (disposed) return Promise.reject(new Error('Task pins are disposed'))
106
+ if (binding === undefined) return Promise.resolve(source.getSnapshot().queue)
107
+ if (refreshPromise !== null) { refreshAgain = true; return refreshPromise }
108
+ refreshPromise = enqueue(readOrder).finally(() => {
109
+ refreshPromise = null
110
+ if (refreshAgain && !disposed && started && binding !== undefined) { refreshAgain = false; void refresh().catch(() => {}) }
111
+ })
112
+ return refreshPromise
113
+ }
114
+ const connectNative = (next) => {
115
+ disconnect?.()
116
+ if (disposed) return () => {}
117
+ if (next?.source?.getSnapshot === undefined || typeof next?.source?.subscribe !== 'function' ||
118
+ typeof next.pinSession !== 'function' || typeof next.unpinSession !== 'function') {
119
+ orderError = 'Native Pin service is unavailable'; publish(); return () => {}
120
+ }
121
+ binding = next; generation++
122
+ let signature, closed = false
123
+ const observe = () => {
124
+ if (closed || disposed) return
125
+ const snapshot = next.source.getSnapshot()
126
+ native = { ...snapshot, pinnedSessionIds: [...(snapshot.pinnedSessionIds ?? [])], archivedSessionIds: [...(snapshot.archivedSessionIds ?? [])] }
127
+ const value = JSON.stringify([native.pinnedSessionIds, native.archivedSessionIds, native.phase, native.state])
128
+ const changed = signature !== value
129
+ signature = value; publish()
130
+ if (changed && nativeReady() && started) void refresh().catch(() => {})
131
+ }
132
+ const off = next.source.subscribe(observe)
133
+ disconnect = () => {
134
+ if (closed) return
135
+ closed = true; off(); stopTimer(); generation++
136
+ if (binding === next) { binding = undefined; native = undefined; loaded = false; writable = false; publish() }
137
+ }
138
+ observe()
139
+ return disconnect
140
+ }
141
+ const setPinned = (sessionId, pinned) => {
142
+ if (disposed || binding === undefined) return Promise.reject(new Error('Native Pin service is unavailable'))
143
+ const prior = pinFlights.get(sessionId)
144
+ if (prior?.pinned === pinned) return prior.promise
145
+ if (!pending.has(sessionId) && nativeReady() && source.getSnapshot().queue.sessionIds.includes(sessionId) === pinned) {
146
+ return Promise.resolve(source.getSnapshot().queue)
147
+ }
148
+ const target = binding
149
+ actionError = null; countPending(sessionId, 1)
150
+ const promise = Promise.resolve().then(() => (pinned ? target.pinSession : target.unpinSession)(sessionId)).then(() => {
151
+ // Native model/follow owns the result. Never force the requested boolean or
152
+ // retry the Pin just because the separate rank service is unavailable.
153
+ publish(); if (started) void refresh().catch(() => {})
154
+ return source.getSnapshot().queue
155
+ }).catch(error => { actionError = String(error?.message ?? error); publish(); throw error }).finally(() => {
156
+ if (pinFlights.get(sessionId)?.promise === promise) pinFlights.delete(sessionId)
157
+ countPending(sessionId, -1)
158
+ })
159
+ pinFlights.set(sessionId, { pinned, promise })
160
+ return promise
161
+ }
162
+ const moveBefore = (sessionId, beforeSessionId) => {
163
+ actionError = null; countPending(sessionId, 1)
164
+ return enqueue(async () => {
165
+ if (!loaded || !writable) await readOrder()
166
+ if (!writable) throw new Error('Task order is not ready or has not been saved; official Pin remains usable')
167
+ for (let attempt = 0; attempt < 2; attempt++) {
168
+ const queue = source.getSnapshot().queue
169
+ if (!queue.sessionIds.includes(sessionId) || beforeSessionId != null && !queue.sessionIds.includes(beforeSessionId)) throw new Error('Task is no longer pinned')
170
+ const { response, payload } = await request('POST', {
171
+ action: 'move', sessionId, beforeSessionId: beforeSessionId ?? null, expectedRevision: rank.revision,
172
+ }, lifetime.signal)
173
+ if (disposed) throw new Error('Task pins are disposed')
174
+ if (response.ok) return adopt(payload)
175
+ const hasCurrentOrder = validQueue(payload.queue)
176
+ if (hasCurrentOrder) adopt(payload)
177
+ if (response.status === 409 && hasCurrentOrder) { if (!writable) break; continue }
178
+ throw new Error(payload.message ?? `Task order write failed (${response.status})`)
179
+ }
180
+ throw new Error('Task order changed again; retry the move')
181
+ }).catch(error => { actionError = String(error?.message ?? error); publish(); throw error }).finally(() => countPending(sessionId, -1))
182
+ }
183
+ return {
184
+ source, connectNative, refresh, setPinned, pinCreated: sessionId => setPinned(sessionId, true), moveBefore,
185
+ start() {
186
+ started = true
187
+ const onFocus = () => { polls = 0; void refresh().catch(() => {}) }
188
+ if (typeof window !== 'undefined') window.addEventListener('focus', onFocus)
189
+ if (binding !== undefined && nativeReady()) void refresh().catch(() => {})
190
+ return () => {
191
+ disposed = true; started = false; stopTimer(); disconnect?.(); lifetime.abort()
192
+ if (typeof window !== 'undefined') window.removeEventListener('focus', onFocus)
193
+ }
194
+ },
195
+ }
196
+ }
197
+
198
+ const TASK_SETTINGS_NAMESPACE = 'better-tasks-ui'
199
+ // 0.2 dsh-settings.describe emits ns=entry.options.id and write resolves that
200
+ // same options.id (lib/index.js:443,502 in 0.2.0-rc.2). Inspect's qualified
201
+ // loader id (include:better-tasks-ui) is NOT the settings namespace.
202
+ const TASK_SETTINGS_ENTRY_ID = TASK_SETTINGS_NAMESPACE
203
+ const TASK_DETAIL_FONT_MIN = 11
204
+ const TASK_DETAIL_FONT_MAX = 16
205
+ const TASK_COLUMN_LAYOUTS = Object.freeze(['auto', 'single', 'double'])
206
+ const TASK_TITLE_EMPHASES = Object.freeze(['project', 'task'])
207
+ /** Sidebar body the browser shows: the native session list or the task swimlane. */
208
+ const TASK_SIDEBAR_VIEWS = Object.freeze(['native', 'tasks'])
209
+ const DEFAULT_TASK_PREFERENCES = Object.freeze({
210
+ detailFontSize: 13,
211
+ defaultTodoExpanded: false,
212
+ defaultGoalExpanded: false,
213
+ defaultFinalExpanded: true,
214
+ defaultInputExpanded: false,
215
+ columnLayout: 'auto',
216
+ titleEmphasis: 'task',
217
+ sidebarView: 'native',
218
+ })
219
+
220
+ const FIELDS = new Set(Object.keys(DEFAULT_TASK_PREFERENCES))
221
+
222
+ function decodeTaskPreferences(value) {
223
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
224
+ const detailFontSize = value.detailFontSize
225
+ const columnLayout = value.columnLayout
226
+ const titleEmphasis = value.titleEmphasis === undefined ? DEFAULT_TASK_PREFERENCES.titleEmphasis : value.titleEmphasis
227
+ if (!TASK_TITLE_EMPHASES.includes(titleEmphasis)) return undefined
228
+ // Absent reads as the default, like titleEmphasis: rows written before this
229
+ // preference existed must keep decoding.
230
+ const sidebarView = value.sidebarView === undefined ? DEFAULT_TASK_PREFERENCES.sidebarView : value.sidebarView
231
+ if (!TASK_SIDEBAR_VIEWS.includes(sidebarView)) return undefined
232
+ if (!Number.isInteger(detailFontSize) || detailFontSize < TASK_DETAIL_FONT_MIN || detailFontSize > TASK_DETAIL_FONT_MAX) return undefined
233
+ if (!TASK_COLUMN_LAYOUTS.includes(columnLayout)) return undefined
234
+ for (const field of ['defaultTodoExpanded', 'defaultGoalExpanded', 'defaultFinalExpanded', 'defaultInputExpanded']) {
235
+ if (typeof value[field] !== 'boolean') return undefined
236
+ }
237
+ return Object.freeze({
238
+ detailFontSize,
239
+ defaultTodoExpanded: value.defaultTodoExpanded,
240
+ defaultGoalExpanded: value.defaultGoalExpanded,
241
+ defaultFinalExpanded: value.defaultFinalExpanded,
242
+ defaultInputExpanded: value.defaultInputExpanded,
243
+ columnLayout,
244
+ titleEmphasis,
245
+ sidebarView,
246
+ })
247
+ }
248
+
249
+ function fallbackSnapshot() {
250
+ return Object.freeze({
251
+ status: 'unavailable',
252
+ value: DEFAULT_TASK_PREFERENCES,
253
+ revision: undefined,
254
+ writable: false,
255
+ mode: 'memory',
256
+ })
257
+ }
258
+
259
+ function createTaskPreferencesStore() {
260
+ let snapshot = fallbackSnapshot()
261
+ let scope
262
+ let unsubscribe
263
+ const listeners = new Set()
264
+
265
+ const publish = (next) => {
266
+ if (snapshot.status === next.status && snapshot.value === next.value && snapshot.revision === next.revision && snapshot.writable === next.writable && snapshot.mode === next.mode) return
267
+ snapshot = Object.freeze(next)
268
+ for (const listener of listeners) listener()
269
+ }
270
+
271
+ const sync = () => {
272
+ if (scope === undefined) return publish(fallbackSnapshot())
273
+ const source = scope.getSnapshot()
274
+ const decoded = decodeTaskPreferences(source.value)
275
+ publish({
276
+ status: source.status,
277
+ value: decoded ?? DEFAULT_TASK_PREFERENCES,
278
+ revision: source.revision,
279
+ // ConfigFormController may report the document's writable=true even when
280
+ // this entry is absent. Never write defaults into an unserved namespace.
281
+ writable: source.status === 'ready' && decoded !== undefined && source.writable === true,
282
+ mode: source.mode,
283
+ })
284
+ }
285
+
286
+ const disconnect = () => {
287
+ unsubscribe?.()
288
+ unsubscribe = undefined
289
+ scope = undefined
290
+ sync()
291
+ }
292
+
293
+ return Object.freeze({
294
+ source: Object.freeze({
295
+ getSnapshot: () => snapshot,
296
+ subscribe(listener) {
297
+ listeners.add(listener)
298
+ return () => listeners.delete(listener)
299
+ },
300
+ }),
301
+ connect(nextScope) {
302
+ disconnect()
303
+ scope = nextScope
304
+ sync()
305
+ unsubscribe = nextScope.subscribe(sync)
306
+ return () => {
307
+ if (scope !== nextScope) return
308
+ disconnect()
309
+ }
310
+ },
311
+ set(field, value) {
312
+ if (!FIELDS.has(field)) return Promise.reject(new TypeError(`unknown Better Tasks setting: ${String(field)}`))
313
+ if (scope === undefined || !snapshot.writable) return Promise.reject(new Error('Better Tasks settings are not writable'))
314
+ const optimistic = decodeTaskPreferences({ ...snapshot.value, [field]: value })
315
+ if (optimistic === undefined) return Promise.reject(new TypeError(`invalid Better Tasks setting: ${field}`))
316
+ const writeScope = scope
317
+ publish({ ...snapshot, value: optimistic })
318
+ const rejected = (error) => {
319
+ if (scope === writeScope) sync()
320
+ throw error
321
+ }
322
+ let result
323
+ try {
324
+ result = writeScope.set(field, value)
325
+ } catch (error) {
326
+ return Promise.reject(error).catch(rejected)
327
+ }
328
+ return Promise.resolve(result).then((accepted) => {
329
+ // 0.2 resolves false after a rejected mutation and recovery read; it does
330
+ // not reject. Legacy settings scopes may resolve void on success.
331
+ if (accepted === false) throw new Error('Better Tasks settings write was rejected by Host')
332
+ return accepted
333
+ }).catch(rejected)
334
+ },
335
+ })
336
+ }
337
+
338
+ const SIDEBAR_CHILD_SLOTS = Object.freeze([
339
+ 'sidebar.brand.mark',
340
+ 'sidebar.brand.name',
341
+ 'sidebar.panellist',
342
+ 'sidebar.panellist.actions',
343
+ 'sidebar.workspaces',
344
+ 'sidebar.settings',
345
+ 'sidebar.footer.action',
346
+ 'sidebar.session.action',
347
+ ])
348
+
349
+ function createSidebarChildDeclarations({ iconsInFactory = false } = {}) {
350
+ return Object.fromEntries(SIDEBAR_CHILD_SLOTS.filter(name => !iconsInFactory || name !== 'sidebar.panellist').map((name) => [name, {
351
+ kind: name === 'sidebar.session.action' ? 'hoc'
352
+ : name === 'sidebar.panellist' || name === 'sidebar.panellist.actions' || name === 'sidebar.footer.action' ? 'list' : 'single',
353
+ scope: 'root',
354
+ }]))
355
+ }
356
+
357
+ const ACTIVITY_RANGE_DAYS = Object.freeze({
358
+ all: null,
359
+ '1d': 1,
360
+ '3d': 3,
361
+ '7d': 7,
362
+ '14d': 14,
363
+ })
364
+
365
+ const DAY_MS = 24 * 60 * 60 * 1000
366
+
367
+ function compareStableRecency(left, right) {
368
+ if (left.updatedAt !== right.updatedAt) return right.updatedAt - left.updatedAt
369
+ const leftId = String(left.id)
370
+ const rightId = String(right.id)
371
+ return leftId < rightId ? -1 : leftId > rightId ? 1 : 0
372
+ }
373
+
374
+ function beforeIdForTaskDrop(orderedIds, overId, half) {
375
+ if (half !== 'before' && half !== 'after') throw new Error(`unsupported drop half: ${half}`)
376
+ const index = orderedIds.indexOf(overId)
377
+ if (index === -1) throw new Error(`unknown drop target: ${overId}`)
378
+ return half === 'before' ? overId : orderedIds[index + 1] ?? null
379
+ }
380
+
381
+ function indexWorkspacesBySession(workspaces) {
382
+ const index = new Map()
383
+ for (const workspace of workspaces) {
384
+ for (const sessionId of workspace.sessionIds) {
385
+ if (!index.has(sessionId)) index.set(sessionId, workspace)
386
+ }
387
+ }
388
+ return index
389
+ }
390
+
391
+ async function loadProjectAppearances(fetchImpl, url = '/api/project-appearance') {
392
+ try {
393
+ const response = await fetchImpl(url, { cache: 'no-store' })
394
+ if (!response.ok) throw new Error(`appearance request failed: ${response.status}`)
395
+ const payload = await response.json()
396
+ if (!Array.isArray(payload.items) || !Array.isArray(payload.icons)) {
397
+ throw new Error('appearance response is missing items or icons')
398
+ }
399
+ return Object.freeze({
400
+ status: 'ready',
401
+ items: payload.items,
402
+ icons: payload.icons,
403
+ colorKeys: Array.isArray(payload.colorKeys) ? payload.colorKeys : [],
404
+ })
405
+ } catch (error) {
406
+ return Object.freeze({
407
+ status: 'unavailable',
408
+ items: [],
409
+ icons: [],
410
+ colorKeys: [],
411
+ error: String(error?.message ?? error),
412
+ })
413
+ }
414
+ }
415
+
416
+ function summarizeGoal(value) {
417
+ if (value === undefined) return Object.freeze({ availability: 'unknown' })
418
+ if (value === null || value.goal.phase === 'complete') return Object.freeze({ availability: 'none' })
419
+ const reason = value.goal.blockedReason
420
+ return Object.freeze({
421
+ availability: 'ready',
422
+ id: value.goal.id,
423
+ phase: value.goal.phase,
424
+ objective: value.goal.objective,
425
+ roundsStarted: value.roundsStarted,
426
+ maxGoalRounds: value.goal.maxGoalRounds,
427
+ blockedReason: reason === undefined ? undefined : Object.freeze({ code: reason.code, message: reason.message }),
428
+ })
429
+ }
430
+
431
+ function summarizeTodos(value) {
432
+ if (value === undefined) return Object.freeze({ availability: 'unknown' })
433
+ if (value === null) return Object.freeze({ availability: 'none' })
434
+ const counts = { pending: 0, inProgress: 0, completed: 0, total: value.length }
435
+ const items = value.map((todo) => {
436
+ if (todo.status === 'pending') counts.pending += 1
437
+ else if (todo.status === 'in_progress') counts.inProgress += 1
438
+ else if (todo.status === 'completed') counts.completed += 1
439
+ return Object.freeze({ content: todo.content, status: todo.status })
440
+ })
441
+ return Object.freeze({ availability: 'ready', ...counts, items: Object.freeze(items) })
442
+ }
443
+
444
+ /** Warning is presentation only: keep idle actions available for stopped tasks. */
445
+ function taskExecutionWarning(summary, pendingInteraction, connectionState, catalogReady = true) {
446
+ if (connectionState === 'disconnected' || connectionState === 'connecting') return { kind: 'disconnected', detail: '' }
447
+ if (pendingInteraction != null || summary.running || !catalogReady) return null
448
+ const execution = summary.projectionValues?.betterTasksExecution
449
+ if (execution == null) return null // No capability / not yet hydrated is not failure.
450
+ if (execution.phase === 'running') return { kind: 'interrupted', detail: '' }
451
+ if (execution.phase !== 'ended') return null
452
+ const kind = execution.outcome
453
+ if (!['error', 'aborted', 'interrupted', 'max-tokens'].includes(kind)) return null
454
+ return { kind, detail: typeof execution.detail === 'string' ? execution.detail : '' }
455
+ }
456
+
457
+ function summarizeSession(summary, pendingInteraction) {
458
+ if (pendingInteraction !== undefined) {
459
+ return Object.freeze({ status: 'block', interaction: pendingInteraction.kind })
460
+ }
461
+ if (summary.running) return Object.freeze({ status: 'running' })
462
+ return Object.freeze({ status: 'idle' })
463
+ }
464
+
465
+ /**
466
+ * Fold native live status through native subagent catalog edges, not fork ancestry.
467
+ * Status events win over older list rows, including explicit false. Walk upward
468
+ * once so grandchildren count even while their direct parent is idle. No retain,
469
+ * polling, execution ownership, or inference from a persisted open turn.
470
+ */
471
+ function runningSessionTreeIds(list, sessionStatuses = new Map()) {
472
+ const rows = list.byId
473
+ const projections = list.projectionsBySession ?? {}
474
+ const parents = new Map()
475
+ const ids = new Set([...Object.keys(rows), ...Object.keys(projections), ...sessionStatuses.keys()])
476
+ for (const id of ids) {
477
+ const catalog = projections[id]?.values?.subagentCatalog ?? rows[id]?.projectionValues?.subagentCatalog
478
+ if (!Array.isArray(catalog)) continue
479
+ for (const child of catalog) {
480
+ ids.add(child.id)
481
+ if (!parents.has(child.id)) parents.set(child.id, new Set())
482
+ parents.get(child.id).add(id)
483
+ }
484
+ }
485
+ const running = new Set([...ids].filter((id) => (sessionStatuses.get(id)?.running ?? rows[id]?.running) === true))
486
+ const queue = [...running]
487
+ for (let index = 0; index < queue.length; index += 1) {
488
+ for (const parent of parents.get(queue[index]) ?? []) {
489
+ if (running.has(parent)) continue // Also bounds malformed cyclic catalogs.
490
+ running.add(parent)
491
+ queue.push(parent)
492
+ }
493
+ }
494
+ return running
495
+ }
496
+
497
+ function projectTask(summary, currentId, pendingInteraction) {
498
+ const projections = summary.projectionValues
499
+ return Object.freeze({
500
+ id: summary.id,
501
+ title: summary.displayTitle ?? summary.title ?? String(summary.id),
502
+ cwd: summary.cwd,
503
+ updatedAt: summary.updatedAt,
504
+ current: summary.id === currentId,
505
+ goal: summarizeGoal(projections?.goal),
506
+ todos: summarizeTodos(projections?.todos),
507
+ session: summarizeSession(summary, pendingInteraction),
508
+ })
509
+ }
510
+
511
+ /** Match the native 0.2 browser's main-view selection, not Agent running state. */
512
+ function currentTaskSessionId(list, panelActive = false) {
513
+ if (panelActive) return undefined
514
+ const rows = Object.values(list.byId)
515
+ // 0.2's catalog removed list.current; retention is updated as the view switches.
516
+ // Never fall back to an obsolete current id once the new contract is present.
517
+ if (rows.some((row) => Object.hasOwn(row, 'retainedBy'))) {
518
+ return rows.find((row) => (row.retainedBy?.mainView ?? 0) > 0)?.id
519
+ }
520
+ return list.current // Retained 0.1.5 compatibility only.
521
+ }
522
+
523
+ /** Derive task cards in the exact Host-owned pin order. */
524
+ function derivePinnedTasks({ list, pinnedSessionIds, pendingInteractions = new Map(), panelActive = false, sessionStatuses = new Map() }) {
525
+ if (!Array.isArray(pinnedSessionIds)) throw new Error('pinnedSessionIds must be an array')
526
+ const seen = new Set()
527
+ const tasks = []
528
+ const currentId = currentTaskSessionId(list, panelActive)
529
+ const runningIds = runningSessionTreeIds(list, sessionStatuses)
530
+ for (const id of pinnedSessionIds) {
531
+ if (seen.has(id)) continue
532
+ seen.add(id)
533
+ const summary = list.byId[id]
534
+ if (summary === undefined || summary.blank || summary.origin === 'subagent') continue
535
+ tasks.push(projectTask({ ...summary, running: runningIds.has(id) }, currentId, pendingInteractions.get(id)))
536
+ }
537
+ return tasks
538
+ }
539
+
540
+ /**
541
+ * Legacy bounded recent projection retained for compatibility tests and rollback.
542
+ * The task UI no longer uses it as membership or ordering authority.
543
+ */
544
+ function deriveRecentTasks({
545
+ list,
546
+ archivedSessionIds = [],
547
+ pendingInteractions = new Map(),
548
+ activityRange = 'all',
549
+ now = Date.now(),
550
+ limit = 10,
551
+ }) {
552
+ if (!Object.hasOwn(ACTIVITY_RANGE_DAYS, activityRange)) {
553
+ throw new Error(`unsupported activity range: ${activityRange}`)
554
+ }
555
+ if (!Number.isSafeInteger(limit) || limit < 0) throw new Error('limit must be a non-negative safe integer')
556
+
557
+ const archived = new Set(archivedSessionIds)
558
+ const days = ACTIVITY_RANGE_DAYS[activityRange]
559
+ const cutoff = days === null ? Number.NEGATIVE_INFINITY : now - days * DAY_MS
560
+ const visible = []
561
+
562
+ for (const id of list.ids) {
563
+ const summary = list.byId[id]
564
+ if (summary === undefined) continue
565
+ if (summary.blank || summary.origin === 'subagent' || archived.has(summary.id)) continue
566
+ if (summary.updatedAt < cutoff) continue
567
+ visible.push(summary)
568
+ }
569
+
570
+ visible.sort(compareStableRecency)
571
+ return visible.slice(0, limit).map((summary) => projectTask(summary, list.current, pendingInteractions.get(summary.id)))
572
+ }
573
+
574
+ // Keep mutations on DSH's native Session, Conversation and Goal transports.
575
+ async function continueIdleTask({ binding, text, goals, submit, getStatus, signal }) {
576
+ if (typeof text !== 'string' || text.trim() === '') throw new Error('Prompt cannot be empty')
577
+ const session = binding?.session
578
+ if (session === undefined) throw new Error('Session is unavailable')
579
+ signal?.throwIfAborted()
580
+ const snapshot = session.getSnapshot()
581
+ if (snapshot.removed || snapshot.openState !== undefined && snapshot.openState !== 'open') throw new Error('Session is unavailable')
582
+ let pending
583
+ let running = snapshot.running
584
+ if (getStatus !== undefined) {
585
+ const statusSource = getStatus(session.sessionId)
586
+ const statuses = typeof statusSource?.getSnapshot === 'function' ? statusSource.getSnapshot() : statusSource
587
+ const status = typeof statuses?.get === 'function' ? statuses.get(session.sessionId) : statuses
588
+ if (status === undefined || status === null || typeof status !== 'object') throw new Error('Session status is unavailable')
589
+ running ||= status?.running === true
590
+ pending = status?.pendingInteraction
591
+ } else {
592
+ // Legacy callers only. Desktop surface callers always supply the live status map.
593
+ pending = snapshot.pendingInteraction
594
+ }
595
+ if (running || pending != null) throw new Error('Session is no longer idle')
596
+ const result = submit === undefined
597
+ ? await session.prompt([{ type: 'text', text }], 'steer', signal)
598
+ : await submit({ session, text, signal })
599
+ const accepted = submit === undefined ? result?.ok === true : result?.kind === 'success'
600
+ if (!accepted) throw new Error(result?.error?.message ?? result?.text ?? 'Prompt submission failed')
601
+
602
+ // Admission has succeeded. Goal inspection/resume failures are warnings only;
603
+ // neither cancellation nor a CAS conflict can resend or roll back this prompt.
604
+ try {
605
+ let goal
606
+ if (typeof goals?.get === 'function') {
607
+ const current = await goals.get(session.sessionId)
608
+ if (!current?.ok) throw new Error(current?.error?.message ?? 'Goal lookup failed')
609
+ goal = current.value
610
+ } else {
611
+ // Keep the pre-0.2 direct-call contract; the surface manager supplies get().
612
+ goal = session.projections?.faceOf('goal')?.getSnapshot()?.goal
613
+ }
614
+ if (goal == null || goal.phase === 'complete' || goal.phase === 'active' && goal.activation === 'armed') return { warning: null }
615
+ if (Number.isFinite(goal.maxGoalRounds) && Number.isFinite(goal.roundsStarted) && goal.roundsStarted >= goal.maxGoalRounds) {
616
+ return { warning: 'Goal was not resumed: its automatic continuation round budget is exhausted' }
617
+ }
618
+ if (typeof goals?.resume !== 'function') throw new Error('Goal service is unavailable')
619
+ const resumed = await goals.resume(session.sessionId, { id: goal.id, revision: goal.revision })
620
+ if (!resumed?.ok) throw new Error(resumed?.error?.message ?? 'Goal resume failed')
621
+ return { warning: null }
622
+ } catch (error) {
623
+ return { warning: String(error?.message ?? error) }
624
+ }
625
+ }
626
+
627
+ async function renameTaskSession({ binding, title, sessions, sessionId, signal }) {
628
+ if (typeof title !== 'string' || title.trim() === '') throw new Error('Title cannot be empty')
629
+ const trimmed = title.trim()
630
+ const rename = async (current, requireOpen = false) => {
631
+ signal?.throwIfAborted()
632
+ if (current?.session === undefined) throw new Error('Session is unavailable')
633
+ const snapshot = current.session.getSnapshot()
634
+ if (snapshot.removed || requireOpen && snapshot.openState !== 'open') throw new Error('Session is unavailable')
635
+ const result = await current.session.rename(trimmed)
636
+ if (!result?.ok) throw new Error(result?.error?.message ?? 'Rename failed')
637
+ }
638
+ if (sessions !== undefined) {
639
+ if (typeof sessionId !== 'string' || sessionId === '') throw new Error('Session is unavailable')
640
+ const operation = async (reference) => {
641
+ const current = await reference.ready
642
+ if (sessions.binding(sessionId) !== current) throw new Error('Session generation changed')
643
+ return rename(current, true)
644
+ }
645
+ if (typeof sessions.using === 'function') {
646
+ return sessions.using(sessionId, { source: 'controllerOperation', signal }, operation)
647
+ }
648
+ if (typeof sessions.retain !== 'function') throw new Error('Session retention is unavailable')
649
+ const reference = sessions.retain(sessionId, { source: 'controllerOperation', signal })
650
+ try { return await operation(reference) } finally { reference.release() }
651
+ }
652
+ return rename(binding)
653
+ }
654
+
655
+ // The official skill source inserts '/name ' as a plain user-text gesture.
656
+ // Keep this contract separate from the command menu and from the textarea UI.
657
+ const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
658
+
659
+ function insertSkillGesture(draft, name, start, end = start) {
660
+ if (typeof draft !== 'string' || typeof name !== 'string' || !SKILL_NAME.test(name)) {
661
+ throw new TypeError('invalid skill gesture')
662
+ }
663
+ const from = Math.max(0, Math.min(draft.length, Number.isInteger(start) ? start : draft.length))
664
+ const to = Math.max(from, Math.min(draft.length, Number.isInteger(end) ? end : from))
665
+ const before = draft.slice(0, from)
666
+ const after = draft.slice(to)
667
+ const separator = before !== '' && !/\s$/.test(before) ? ' ' : ''
668
+ const gesture = `/${name} `
669
+ return { text: `${before}${separator}${gesture}${after}`, caret: before.length + separator.length + gesture.length }
670
+ }
671
+
672
+ async function listTaskSkills(catalog, sessionId, signal) {
673
+ if (typeof sessionId !== 'string' || sessionId === '' || typeof catalog?.list !== 'function') {
674
+ throw new TypeError('skill catalog unavailable for session')
675
+ }
676
+ const result = await catalog.list({ sessionId }, signal)
677
+ if (!result?.ok) throw new Error(result?.error?.message ?? 'skills/list failed')
678
+ if (!Array.isArray(result.value?.skills)) throw new TypeError('invalid skills/list response')
679
+ return result.value.skills.filter((skill) => skill && SKILL_NAME.test(skill.name))
680
+ }
681
+
682
+ const taskInputAbortReason = (signal) => signal?.reason ?? new Error('Input surface creation aborted')
683
+
684
+ // A waiting caller can leave promptly without cancelling a predecessor's admitted
685
+ // operation. The predecessor/factory keeps its own completion cleanup attached.
686
+ function taskInputWaitWithSignal(promise, signal) {
687
+ if (signal?.aborted) return Promise.reject(taskInputAbortReason(signal))
688
+ if (signal === undefined) return promise
689
+ return new Promise((resolve, reject) => {
690
+ const onAbort = () => { cleanup(); reject(taskInputAbortReason(signal)) }
691
+ const cleanup = () => signal.removeEventListener('abort', onAbort)
692
+ signal.addEventListener('abort', onAbort, { once: true })
693
+ Promise.resolve(promise).then(value => { cleanup(); resolve(value) }, error => { cleanup(); reject(error) })
694
+ })
695
+ }
696
+
697
+ /** One task-owned native surface per Session, never the main composer's binding. */
698
+ function createTaskInputManager({ getConversation, getSessions, getStatus, getGoals, isPinned }) {
699
+ const entries = new Map()
700
+ const drafts = new Map()
701
+ const lifetime = new AbortController()
702
+ let disposed = false
703
+ const pinned = id => isPinned === undefined || isPinned(id)
704
+ const freshGoals = {
705
+ get: id => {
706
+ const goals = getGoals?.()
707
+ if (typeof goals?.get !== 'function') throw new Error('Goal service is unavailable')
708
+ return goals.get(id)
709
+ },
710
+ resume: (id, ref) => {
711
+ const goals = getGoals?.()
712
+ if (typeof goals?.resume !== 'function') throw new Error('Goal service is unavailable')
713
+ return goals.resume(id, ref)
714
+ },
715
+ }
716
+
717
+ function close(entry) {
718
+ if (entry.closing) return
719
+ entry.closing = true
720
+ // This signal closes UI/creation. The native surface retains any admitted
721
+ // operation; its attempt signal is deliberately not replaced by this one.
722
+ entry.controller.abort(new Error('Task input surface closed'))
723
+ entry.handle?.dispose()
724
+ }
725
+
726
+ async function create(sessionId, { signal, onWarning } = {}) {
727
+ if (typeof sessionId !== 'string' || sessionId === '') throw new Error('Session is unavailable')
728
+ const waitingSignal = signal === undefined ? lifetime.signal : AbortSignal.any([signal, lifetime.signal])
729
+ waitingSignal.throwIfAborted()
730
+ if (disposed) throw new Error('Task input manager is disposed')
731
+ while (entries.has(sessionId)) {
732
+ const previous = entries.get(sessionId)
733
+ if (!previous.closing) throw new Error('Task input surface is already active')
734
+ await taskInputWaitWithSignal(previous.finished, waitingSignal)
735
+ waitingSignal.throwIfAborted()
736
+ }
737
+ if (!pinned(sessionId)) { drafts.delete(sessionId); throw new Error('Task is no longer pinned') }
738
+ const conversation = getConversation?.()
739
+ if (typeof conversation?.input?.createSurface !== 'function') throw new Error('Native task input surface is unavailable')
740
+ const controller = new AbortController()
741
+ let resolveFinished
742
+ const entry = { controller, closing: false, handle: undefined, stop: undefined, done: false,
743
+ finished: new Promise(resolve => { resolveFinished = resolve }) }
744
+ entries.set(sessionId, entry)
745
+ const onAbort = () => close(entry)
746
+ waitingSignal.addEventListener('abort', onAbort, { once: true })
747
+ const save = () => {
748
+ if (entries.get(sessionId) !== entry) return
749
+ if (disposed || !pinned(sessionId)) { drafts.delete(sessionId); return }
750
+ const text = entry.handle?.state.getSnapshot().draft
751
+ if (typeof text === 'string') drafts.set(sessionId, text)
752
+ }
753
+ const finish = () => {
754
+ if (entry.done) return
755
+ entry.done = true
756
+ // Preserve late native failure-restoration before removing the subscriber.
757
+ try { save() } finally {
758
+ try { entry.stop?.() } finally {
759
+ waitingSignal.removeEventListener('abort', onAbort)
760
+ if (entries.get(sessionId) === entry) entries.delete(sessionId)
761
+ resolveFinished()
762
+ }
763
+ }
764
+ }
765
+ const creation = Promise.resolve().then(() => {
766
+ controller.signal.throwIfAborted()
767
+ return conversation.input.createSurface({
768
+ sessionId, signal: controller.signal,
769
+ ...(drafts.has(sessionId) ? { initialDraft: drafts.get(sessionId) } : {}),
770
+ defaultSink: async ({ binding, text, signal: attemptSignal }) => {
771
+ const sessions = getSessions?.()
772
+ if (binding?.session?.sessionId !== sessionId || sessions?.binding(sessionId) !== binding) {
773
+ throw new Error('Session generation changed')
774
+ }
775
+ const sender = getConversation?.()
776
+ if (typeof sender?.sendSession !== 'function') throw new Error('Native Session sender is unavailable')
777
+ const { warning } = await continueIdleTask({
778
+ binding, text, goals: freshGoals, getStatus, signal: attemptSignal,
779
+ submit: ({ session, text: message, signal: sendSignal }) => sender.sendSession(session, message, [], 'steer', sendSignal),
780
+ })
781
+ if (warning !== null) {
782
+ // The prompt is already accepted, including when the task UI closed
783
+ // during flight. Keep the existing global warning channel alive.
784
+ try { entry.handle?.input.notify('info', warning) } catch { /* warning delivery cannot fail admission */ }
785
+ try { onWarning?.(warning) } catch { /* caller notification cannot fail admission */ }
786
+ }
787
+ return { kind: 'success' }
788
+ },
789
+ })
790
+ }).then(handle => {
791
+ entry.handle = handle
792
+ try {
793
+ entry.stop = handle.state.subscribe(save)
794
+ save()
795
+ } catch (error) {
796
+ close(entry)
797
+ Promise.resolve(handle.closed).then(finish, finish).catch(() => {})
798
+ throw error
799
+ }
800
+ Promise.resolve(handle.closed).then(finish, finish).catch(() => {})
801
+ if (entry.closing || waitingSignal.aborted || disposed) {
802
+ handle.dispose()
803
+ throw taskInputAbortReason(waitingSignal.aborted ? waitingSignal : controller.signal)
804
+ }
805
+ return { ...handle, dispose: () => close(entry) }
806
+ }, error => {
807
+ finish()
808
+ throw error
809
+ })
810
+ // Race only the caller's creation wait. A late factory success is still
811
+ // disposed above and its final restored draft collected after closed.
812
+ return taskInputWaitWithSignal(creation, waitingSignal)
813
+ }
814
+
815
+ return {
816
+ create,
817
+ rename(sessionId, title) {
818
+ if (disposed) return Promise.reject(new Error('Task input manager is disposed'))
819
+ const sessions = getSessions?.()
820
+ if (sessions === undefined) return Promise.reject(new Error('Session retention is unavailable'))
821
+ return renameTaskSession({ sessions, sessionId, title })
822
+ },
823
+ dispose() {
824
+ if (disposed) return
825
+ disposed = true
826
+ lifetime.abort(new Error('Task input manager is disposed'))
827
+ for (const entry of entries.values()) close(entry)
828
+ drafts.clear()
829
+ },
830
+ }
831
+ }
832
+
833
+ const name = 'dsh-better-tasks'
834
+ // The shared question surface is optional: older/native question packages keep
835
+ // their composer behavior without delaying sidebar, Session, or Workspace startup.
836
+ const inject = ['slots', 'layout', 'locale']
837
+
838
+ const NS = 'better-tasks-sidebar'
839
+ const STYLE_ID = 'dsh-better-tasks/sidebar'
840
+ const TASK_GRID_TWO_COLUMN_THRESHOLD = 528
841
+ const FINAL_CACHE_KEY = 'dsh-better-tasks.final-message-cache.v1'
842
+ /**
843
+ * Build revision, injected by scripts/build-client.mjs as the sha256 of this
844
+ * plugin's client-side sources. Every render-probe payload carries it: the
845
+ * browser's localStorage is an append log, so records from earlier builds stay
846
+ * forever and only payloads whose revision matches the delivered build count.
847
+ */
848
+ const BUILD_REVISION = 'da11927b17db72c2a819ac8fc74933039cf592c8ba30b2b9efc45149c0b9c0ca'
849
+ /** Render probe: on by default, so a delivered build can prove the column rendered. */
850
+ const RENDER_PROBE = true
851
+ /** Per-render payload; rewritten on every invocation. */
852
+ const RENDER_PROBE_KEY = 'dbt-render-probe'
853
+ /** One-shot environment facts for the same build. */
854
+ const RENDER_ENV_KEY = 'dbt-render-env'
855
+ /** Cumulative call-mix ledger; survives the per-frame payload being overwritten. */
856
+ const RENDER_LEDGER_KEY = 'dbt-render-ledger'
857
+ /** The inner component's own throw, captured in the same fiber before it escapes. */
858
+ const RENDER_ERROR_KEY = 'dbt-render-error'
859
+ /** The view this plugin kept in localStorage before D1 moved it to the Config. */
860
+ const LEGACY_VIEW_KEY = 'dsh-better-tasks.view'
861
+ /** Set once the legacy localStorage view has been folded into the preference. */
862
+ const VIEW_MIGRATED_KEY = 'dsh-better-tasks.view-migrated'
863
+
864
+ let renderProbeCount = 0
865
+ let renderBareCount = 0
866
+ let renderReactCount = 0
867
+ let firstBareStack = null
868
+
869
+ /** Diagnostics must never break a render. */
870
+ function writeStorage(key, value) {
871
+ try { localStorage.setItem(key, value) } catch {}
872
+ }
873
+
874
+ /**
875
+ * Capture the call site of a bare invocation.
876
+ *
877
+ * Taken while this frame is still the top of the stack, so the frames above it are
878
+ * the caller's. This is the only way to name the path that invokes the registered
879
+ * component outside React.
880
+ * @returns the first 25 frames, truncated to 1500 characters.
881
+ */
882
+ function captureCallStack() {
883
+ try {
884
+ return String(new Error().stack ?? '').split('\n').slice(0, 25).join('\n').slice(0, 1500)
885
+ } catch {
886
+ return ''
887
+ }
888
+ }
889
+
890
+ /**
891
+ * Write one render-probe payload. Called at the very top of the guarded root,
892
+ * BEFORE any dry-run short circuit: a guard that returns first would hide the
893
+ * bare call this probe exists to observe, and the A/B question would stay
894
+ * unanswered forever while looking like "did not crash".
895
+ * @param input - the frame's observations.
896
+ */
897
+ function writeRenderProbe(input) {
898
+ renderProbeCount += 1
899
+ if (input.zeroArg) renderBareCount += 1
900
+ if (input.hookOk && !input.zeroArg) renderReactCount += 1
901
+ if (input.zeroArg && firstBareStack === null) firstBareStack = input.stack ?? ''
902
+ const via = input.zeroArg && !input.hookOk ? 'bare' : (input.hookOk ? 'react' : 'unknown')
903
+ writeStorage(RENDER_PROBE_KEY, JSON.stringify({
904
+ revision: BUILD_REVISION,
905
+ rendered: renderProbeCount,
906
+ zeroArg: input.zeroArg,
907
+ isElement: input.isElement,
908
+ propKeys: input.propKeys,
909
+ durationMs: input.durationMs,
910
+ // The A/B criterion itself: a hook is legal here only on a React render
911
+ // stack, so a failed probe means this call site is not React rendering.
912
+ hookOk: input.hookOk,
913
+ via,
914
+ zeroArgCount: renderBareCount,
915
+ // Only on a bare call, and only from the true call site.
916
+ ...(input.zeroArg ? { stack: input.stack ?? '' } : {}),
917
+ }))
918
+ // One overwritten key cannot show a MIX of callers: the last frame wins, so
919
+ // "all six were bare" and "five bare then one React render" look identical.
920
+ // The ledger keeps the totals, making `react: 0` a provable fact.
921
+ writeRenderLedger()
922
+ }
923
+
924
+ /**
925
+ * Rewrite the cumulative ledger. Shared by the per-frame probe and the inner
926
+ * component's throw capture, so the error count is visible in the same record.
927
+ */
928
+ function writeRenderLedger() {
929
+ writeStorage(RENDER_LEDGER_KEY, JSON.stringify({
930
+ revision: BUILD_REVISION,
931
+ total: renderProbeCount,
932
+ react: renderReactCount,
933
+ bare: renderBareCount,
934
+ zeroArgCount: renderBareCount,
935
+ firstBareStack,
936
+ }))
937
+ }
938
+ const dictionaries = {
939
+ zh: {
940
+ 'brand.fallback': 'DSH',
941
+ 'session.new': '新会话',
942
+ 'session.new.label': '新建会话',
943
+ 'toggle.open': '打开侧边栏',
944
+ 'toggle.collapse': '收起侧边栏',
945
+ 'panels.label': '全局面板',
946
+ 'panels.more': '更多',
947
+ 'panels.unpin': '取消固定 {name}',
948
+ 'view.native': '会话',
949
+ 'view.tasks': '任务',
950
+ 'tasks.goal': 'Goal',
951
+ 'tasks.todo': 'Todo',
952
+ 'tasks.todoCompleted': '已完成',
953
+ 'tasks.todoInProgress': '进行中',
954
+ 'tasks.todoPending': '待处理',
955
+ 'tasks.empty': '还没有置顶会话',
956
+ 'tasks.orderRestoring': '正在恢复任务顺序…',
957
+ 'tasks.pinMigration': '正在合并已有置顶任务…',
958
+ 'tasks.expandGoal': '展开 Goal',
959
+ 'tasks.collapseGoal': '收起 Goal',
960
+ 'tasks.expandTodo': '展开 Todo',
961
+ 'tasks.collapseTodo': '收起 Todo',
962
+ 'tasks.final': 'Final',
963
+ 'tasks.expandFinal': '展开 Final message',
964
+ 'tasks.collapseFinal': '收起 Final message',
965
+ 'tasks.finalLoading': '正在读取 Final message…',
966
+ 'tasks.finalNone': '没有 Final message',
967
+ 'tasks.finalError': 'Final message 读取失败',
968
+ 'tasks.finalRetry': '重试',
969
+ 'tasks.finalTruncated': '内容已截断',
970
+ 'tasks.copyCode': '复制代码',
971
+ 'tasks.copiedCode': '已复制',
972
+ 'tasks.footnotes': '脚注',
973
+ 'tasks.unpin': '从任务中移除',
974
+ 'tasks.continue': '继续任务',
975
+ 'tasks.continueDefault': '继续',
976
+ 'tasks.inputLoading': '正在打开输入框…',
977
+ 'tasks.inputSearch': '搜索技能或指令…',
978
+ 'tasks.inputEmpty': '未找到匹配的技能或指令',
979
+ 'tasks.inputCatalogError': '部分项目加载失败,请收起后重试',
980
+ 'tasks.retryInput': '重试',
981
+ 'tasks.expandInput': '展开续行输入栏',
982
+ 'tasks.collapseInput': '收起续行输入栏',
983
+ 'tasks.goalResumeWarning': '消息已发送,但 Goal 恢复失败',
984
+ 'tasks.send': '发送',
985
+ 'tasks.add': '添加',
986
+ 'tasks.skillSearch': '搜索技能',
987
+ 'tasks.skillLoading': '正在加载…',
988
+ 'tasks.skillEmpty': '没有匹配的技能',
989
+ 'tasks.skillUnavailable': '技能列表暂不可用',
990
+ 'tasks.rename': '重命名会话',
991
+ 'tasks.renameAction': '重命名',
992
+ 'tasks.actionUnavailable': '当前会话状态已变化,请重试',
993
+ 'tasks.runningAria': '运行中',
994
+ 'tasks.blockAria': '等待交互',
995
+ 'tasks.answer': '去回答',
996
+ 'tasks.questionLoading': '正在加载问题选项…',
997
+ 'tasks.answerHint': '打开该会话的问题面板',
998
+ 'tasks.answerFailed': '没能打开问题面板,请打开会话作答',
999
+ 'tasks.idleAria': '空闲',
1000
+ 'tasks.execution.disconnected': '连接已断开 · 任务状态未知',
1001
+ 'tasks.execution.error': '执行失败',
1002
+ 'tasks.execution.aborted': '执行已停止',
1003
+ 'tasks.execution.interrupted': '执行已中断',
1004
+ 'tasks.execution.max-tokens': '执行已中断 · 达到输出上限',
1005
+ 'tasks.appearanceUnavailable': '项目外观暂不可用',
1006
+ 'tasks.unknown': '未知',
1007
+ 'tasks.none': '无',
1008
+ 'tasks.editAppearance': '设置项目图标和颜色',
1009
+ 'tasks.appearanceTitle': '项目外观',
1010
+ 'tasks.iconSearch': '搜索图标',
1011
+ 'tasks.cancel': '取消',
1012
+ 'tasks.save': '保存',
1013
+ 'settings.nav': '任务视图',
1014
+ 'settings.title': '任务视图',
1015
+ 'settings.description': '自定义任务卡投影的显示方式。修改会立即生效。',
1016
+ 'settings.fontSize': '详情正文字号',
1017
+ 'settings.fontSizeHint': '影响 Todo、Goal、Final 的展开正文及任务卡问题区。',
1018
+ 'settings.titleEmphasis': '任务卡名称强调',
1019
+ 'settings.titleEmphasis.project': '强调项目名',
1020
+ 'settings.titleEmphasis.task': '强调任务名',
1021
+ 'settings.titleEmphasisHint': '仅调整项目名与任务名的字号、字重和主次色。',
1022
+ 'settings.defaultExpansion': '默认展开',
1023
+ 'settings.todoExpanded': 'Todo',
1024
+ 'settings.goalExpanded': 'Goal',
1025
+ 'settings.finalExpanded': 'Final',
1026
+ 'settings.inputExpanded': '快速消息栏',
1027
+ 'settings.expansionHint': '默认值适用于未手动调整的内容。Goal 换目标、Todo 换目标或清单内容、Final 和快速消息栏进入新空闲周期时恢复默认;仅进度变化不重置 Todo。当前正在浏览的任务进入新空闲周期时不自动展开 Final;手动展开仍有效。快速消息栏仅在空闲卡片的 Final 展开时显示。',
1028
+ 'settings.columns': '任务列布局',
1029
+ 'settings.columns.auto': '自动',
1030
+ 'settings.columns.single': '单列',
1031
+ 'settings.columns.double': '双列',
1032
+ 'settings.columnsHint': '自动模式在侧边栏宽度大于 528px 时使用双列。',
1033
+ 'settings.loading': '正在读取设置…',
1034
+ 'settings.unavailable': '设置暂不可用,当前使用默认值。',
1035
+ 'settings.readOnly': '当前连接只读。',
1036
+ 'settings.writeFailed': '保存失败,已恢复 Host 设置。',
1037
+ 'time.now': '刚刚',
1038
+ 'time.minutes': '{n}分钟',
1039
+ 'time.hours': '{n}小时',
1040
+ 'time.days': '{n}天',
1041
+ 'time.months': '{n}个月',
1042
+ 'time.years': '{n}年',
1043
+ },
1044
+ en: {
1045
+ 'brand.fallback': 'DSH',
1046
+ 'session.new': 'New Session',
1047
+ 'session.new.label': 'New session',
1048
+ 'toggle.open': 'Open sidebar',
1049
+ 'toggle.collapse': 'Collapse sidebar',
1050
+ 'panels.label': 'Global panels',
1051
+ 'panels.more': 'More',
1052
+ 'panels.unpin': 'Unpin {name}',
1053
+ 'view.native': 'Sessions',
1054
+ 'view.tasks': 'Tasks',
1055
+ 'tasks.goal': 'Goal',
1056
+ 'tasks.todo': 'Todo',
1057
+ 'tasks.todoCompleted': 'Completed',
1058
+ 'tasks.todoInProgress': 'In progress',
1059
+ 'tasks.todoPending': 'Pending',
1060
+ 'tasks.empty': 'No pinned sessions yet',
1061
+ 'tasks.orderRestoring': 'Restoring task order…',
1062
+ 'tasks.pinMigration': 'Importing existing pinned tasks…',
1063
+ 'tasks.expandGoal': 'Expand Goal',
1064
+ 'tasks.collapseGoal': 'Collapse Goal',
1065
+ 'tasks.expandTodo': 'Expand Todo',
1066
+ 'tasks.collapseTodo': 'Collapse Todo',
1067
+ 'tasks.final': 'Final',
1068
+ 'tasks.expandFinal': 'Expand final message',
1069
+ 'tasks.collapseFinal': 'Collapse final message',
1070
+ 'tasks.finalLoading': 'Loading final message…',
1071
+ 'tasks.finalNone': 'No final message',
1072
+ 'tasks.finalError': 'Failed to load final message',
1073
+ 'tasks.finalRetry': 'Retry',
1074
+ 'tasks.finalTruncated': 'Content truncated',
1075
+ 'tasks.copyCode': 'Copy code',
1076
+ 'tasks.copiedCode': 'Copied',
1077
+ 'tasks.footnotes': 'Footnotes',
1078
+ 'tasks.unpin': 'Unpin task',
1079
+ 'tasks.continue': 'Continue task',
1080
+ 'tasks.continueDefault': 'Continue',
1081
+ 'tasks.inputLoading': 'Opening input…',
1082
+ 'tasks.inputSearch': 'Search skills or commands…',
1083
+ 'tasks.inputEmpty': 'No matching skills or commands',
1084
+ 'tasks.inputCatalogError': 'Some items could not load. Close and reopen to retry.',
1085
+ 'tasks.retryInput': 'Retry',
1086
+ 'tasks.expandInput': 'Expand follow-up input',
1087
+ 'tasks.collapseInput': 'Collapse follow-up input',
1088
+ 'tasks.goalResumeWarning': 'Message sent, but Goal resume failed',
1089
+ 'tasks.send': 'Send',
1090
+ 'tasks.add': 'Add',
1091
+ 'tasks.skillSearch': 'Search skills',
1092
+ 'tasks.skillLoading': 'Loading…',
1093
+ 'tasks.skillEmpty': 'No matching skills',
1094
+ 'tasks.skillUnavailable': 'Skills are unavailable',
1095
+ 'tasks.rename': 'Rename session',
1096
+ 'tasks.renameAction': 'Rename',
1097
+ 'tasks.actionUnavailable': 'Session state changed; please try again',
1098
+ 'tasks.runningAria': 'Running',
1099
+ 'tasks.blockAria': 'Waiting for interaction',
1100
+ 'tasks.answer': 'Go answer',
1101
+ 'tasks.questionLoading': 'Loading question options…',
1102
+ 'tasks.answerHint': 'Open this Session’s question panel',
1103
+ 'tasks.answerFailed': 'Could not open the question panel — open the Session to answer',
1104
+ 'tasks.idleAria': 'Idle',
1105
+ 'tasks.execution.disconnected': 'Connection lost · task status unknown',
1106
+ 'tasks.execution.error': 'Execution failed',
1107
+ 'tasks.execution.aborted': 'Execution stopped',
1108
+ 'tasks.execution.interrupted': 'Execution interrupted',
1109
+ 'tasks.execution.max-tokens': 'Execution stopped · token limit reached',
1110
+ 'tasks.appearanceUnavailable': 'Project appearance is unavailable',
1111
+ 'tasks.unknown': 'Unknown',
1112
+ 'tasks.none': 'None',
1113
+ 'tasks.editAppearance': 'Set project icon and color',
1114
+ 'tasks.appearanceTitle': 'Project appearance',
1115
+ 'tasks.iconSearch': 'Search icons',
1116
+ 'tasks.cancel': 'Cancel',
1117
+ 'tasks.save': 'Save',
1118
+ 'settings.nav': 'Task View',
1119
+ 'settings.title': 'Task View',
1120
+ 'settings.description': 'Customize how task-card projections are displayed. Changes apply immediately.',
1121
+ 'settings.fontSize': 'Detail text size',
1122
+ 'settings.fontSizeHint': 'Affects expanded Todo, Goal, and Final text, plus task-card questions.',
1123
+ 'settings.titleEmphasis': 'Task card name emphasis',
1124
+ 'settings.titleEmphasis.project': 'Emphasize project',
1125
+ 'settings.titleEmphasis.task': 'Emphasize task',
1126
+ 'settings.titleEmphasisHint': 'Changes only the project and task name size, weight, and color.',
1127
+ 'settings.defaultExpansion': 'Default expansion',
1128
+ 'settings.todoExpanded': 'Todo',
1129
+ 'settings.goalExpanded': 'Goal',
1130
+ 'settings.finalExpanded': 'Final',
1131
+ 'settings.inputExpanded': 'Quick message bar',
1132
+ 'settings.expansionHint': 'Defaults apply where no manual choice exists. Goal resets for a new goal; Todo for a new goal or list contents; Final and the quick message bar for a new idle cycle. Progress-only changes keep Todo choices. The currently browsed task does not automatically open Final for a new idle cycle; manual opening still works. The quick message bar appears only on idle cards with Final expanded.',
1133
+ 'settings.columns': 'Task columns',
1134
+ 'settings.columns.auto': 'Auto',
1135
+ 'settings.columns.single': 'Single',
1136
+ 'settings.columns.double': 'Double',
1137
+ 'settings.columnsHint': 'Auto uses two columns when the sidebar is wider than 528px.',
1138
+ 'settings.loading': 'Loading settings…',
1139
+ 'settings.unavailable': 'Settings are unavailable; defaults are active.',
1140
+ 'settings.readOnly': 'This connection is read-only.',
1141
+ 'settings.writeFailed': 'Save failed; Host settings were restored.',
1142
+ 'time.now': 'now',
1143
+ 'time.minutes': '{n}min',
1144
+ 'time.hours': '{n}h',
1145
+ 'time.days': '{n}d',
1146
+ 'time.months': '{n}mo',
1147
+ 'time.years': '{n}y',
1148
+ },
1149
+ }
1150
+
1151
+ // Single numeric source for sidebar presentation; upstream semantic tokens stay native.
1152
+ const SIDEBAR_LAYOUT = Object.freeze({
1153
+ newSessionHeight: 38, newSessionFontSize: 14, newSessionLineHeight: 22,
1154
+ iconTarget: 34, railTarget: 36, iconSize: 18, unpinTarget: 18,
1155
+ navigationGap: 4, maxNavigationRows: 2, tooltipDelayMs: 500,
1156
+ })
1157
+
1158
+ const css = `
1159
+ .dbt-sidebar{--dbt-pad:12px;--dbt-nav-target:${SIDEBAR_LAYOUT.iconTarget}px;--dbt-rail-target:${SIDEBAR_LAYOUT.railTarget}px;--dbt-nav-gap:${SIDEBAR_LAYOUT.navigationGap}px;--dbt-nav-rows:${SIDEBAR_LAYOUT.maxNavigationRows};--dbt-new-height:${SIDEBAR_LAYOUT.newSessionHeight}px;--dbt-new-font:${SIDEBAR_LAYOUT.newSessionFontSize}px;--dbt-new-line-height:${SIDEBAR_LAYOUT.newSessionLineHeight}px;--dbt-unpin-target:${SIDEBAR_LAYOUT.unpinTarget}px;box-sizing:border-box;width:100%;height:100%;min-height:0;display:flex;flex-direction:column;gap:8px;padding:10px var(--dbt-pad);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);overflow:hidden}
1160
+ .dbt-sidebar[data-wide="false"]{--dbt-pad:8px;align-items:center;padding-inline:8px}
1161
+ .dbt-brand-row{width:100%;height:32px;display:flex;align-items:center;gap:6px;flex:none}
1162
+ .dbt-brand,.dbt-icon-button,.dbt-new-session,.dbt-panel-row{border:0;font:inherit;color:inherit;cursor:pointer}
1163
+ .dbt-brand{min-width:0;display:flex;align-items:center;gap:8px;flex:1;background:transparent;padding:0 4px;text-align:left}
1164
+ .dbt-brand-mark,.dbt-panel-glyph,.dbt-rail-mark{display:inline-flex;align-items:center;justify-content:center;flex:none}
1165
+ .dbt-brand-name,.dbt-panel-label,.dbt-new-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1166
+ .dbt-brand-name{font-weight:650}
1167
+ .dbt-icon-button{corner-shape:round;width:28px;height:28px;color:var(--dsw-alias-label-secondary);border:0;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;padding:0;background:transparent;flex:none}
1168
+ .dbt-icon-button:hover,.dbt-panel-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
1169
+ .dbt-sidebar[data-wide="false"] .dbt-brand-row{height:36px}
1170
+ .dbt-sidebar[data-wide="false"] .dbt-icon-button{width:36px;height:36px;color:var(--dsw-alias-label-primary)}
1171
+ .dbt-sidebar[data-wide="false"] .dbt-toggle .dbt-panel-icon{display:none}
1172
+ .dbt-sidebar[data-wide="false"] .dbt-toggle:hover .dbt-panel-icon{display:inline}
1173
+ .dbt-sidebar[data-wide="false"] .dbt-toggle:hover .dbt-rail-mark{display:none}
1174
+ /* Desktop collapses this column to zero, not to the browser's icon rail.
1175
+ Match the native sidebar's Windows caption affordances instead of changing
1176
+ the layout owner or forcing a phantom rail width. */
1177
+ html[data-windows-titlebar] .dbt-sidebar .dbt-toggle{position:fixed;top:calc((var(--dsh-windows-titlebar-height) - 28px) / 2);left:12px;z-index:30;width:28px;height:28px;-webkit-app-region:no-drag}
1178
+ html[data-windows-titlebar] .dbt-sidebar[data-wide="false"] .dbt-brand-row{height:0}
1179
+ html[data-windows-titlebar] .dbt-sidebar[data-wide="false"]{padding:0}
1180
+ html[data-windows-titlebar] .dbt-sidebar[data-wide="false"] .dbt-toggle .dbt-panel-icon{display:inline}
1181
+ html[data-windows-titlebar] .dbt-sidebar .dbt-toggle .dbt-rail-mark{display:none}
1182
+ html[data-windows-titlebar] .dbt-sidebar[data-wide="false"] .dbt-new-session{position:fixed;top:calc((var(--dsh-windows-titlebar-height) - 28px) / 2);left:48px;z-index:30;width:28px;height:28px;margin:0;padding:0;border-radius:50%;background:transparent;color:var(--dsw-alias-label-secondary);-webkit-app-region:no-drag}
1183
+ html[data-windows-titlebar] .dbt-sidebar[data-wide="false"] .dbt-new-session:hover{background:var(--dsw-alias-interactive-bg-hover)}
1184
+ html[data-windows-titlebar] .dbt-sidebar[data-wide="false"] .dbt-panel-list,html[data-windows-titlebar] .dbt-sidebar[data-wide="false"] .dbt-workspaces,html[data-windows-titlebar] .dbt-sidebar[data-wide="false"] .dbt-foot{display:none}
1185
+ html[data-windows-titlebar]:has(.dbt-sidebar[data-wide="false"]){--dsh-windows-menu-start:84px}
1186
+ .dbt-shell-leading{display:flex;align-items:center;gap:8px;-webkit-app-region:no-drag}
1187
+ /* Native New Session tokens; the rc.3 HOC anchor is display:contents.
1188
+ The row owns spacing, so optional actions sit beside (not inside) the button. */
1189
+ .dbt-new-session-row{width:calc(100% - 8px);min-width:0;display:flex;align-items:center;gap:6px;flex:none;margin:0 4px}
1190
+ .dbt-new-session{container-type:inline-size;position:relative;flex:1 1 auto;min-width:0;display:flex;align-items:center;justify-content:center;gap:0;width:auto;height:var(--dbt-new-height);padding:8px 12px;margin:0;box-sizing:border-box;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-button-elevated-fill);color:var(--dsw-alias-label-primary);font-size:var(--dbt-new-font);font-weight:500;line-height:var(--dbt-new-line-height);cursor:pointer;overflow:hidden}
1191
+ .dbt-new-session:hover{background:var(--dsw-alias-button-floating-hover)}
1192
+ .dbt-new-label-mask{flex:1;min-width:0}
1193
+ .dbt-new-content{display:flex;width:100cqw;align-items:center;justify-content:center;gap:6px}
1194
+ .dbt-new-content>svg{flex:none}
1195
+ .dbt-new-label{max-width:200px;overflow:hidden;white-space:nowrap}
1196
+ .dbt-new-shortcut{opacity:0;display:inline-flex;flex:none;pointer-events:none;font-weight:400}
1197
+ .dbt-new-session:is(:hover,:focus-visible) .dbt-new-shortcut{opacity:1}
1198
+ .dbt-new-session:is(:hover,:focus-visible) .dbt-new-label-mask:has(+.dbt-new-shortcut){overflow:hidden;mask-image:linear-gradient(to right,black calc(100% - 16px),transparent)}
1199
+ html[data-platform="darwin"] .dbt-new-session{background:var(--dsw-specific-sidebar-glass-fill)}
1200
+ html[data-platform="darwin"] .dbt-new-session:hover{background:var(--dsw-specific-sidebar-glass-hover)}
1201
+ .dbt-sidebar[data-wide="false"] .dbt-new-session-row{width:36px;flex-direction:column;align-items:center;gap:4px;margin:0}
1202
+ .dbt-sidebar[data-wide="false"] .dbt-new-session{flex:none;width:36px;height:36px;justify-content:center;padding:0;margin:0;border-color:transparent;border-radius:var(--dsw-radius-md);background:transparent}
1203
+ .dbt-sidebar[data-wide="false"] .dbt-new-session:hover{background:var(--dsw-alias-interactive-bg-hover)}
1204
+ .dbt-panel-list{width:100%;display:flex;flex-direction:row;flex-wrap:wrap;align-items:center;gap:var(--dbt-nav-gap);flex:none;max-height:calc(var(--dbt-nav-target)*var(--dbt-nav-rows) + var(--dbt-nav-gap)*(var(--dbt-nav-rows) - 1));overflow-y:auto;overflow-x:hidden;padding-block:3px;scrollbar-width:thin}
1205
+ .dbt-panel-row{width:var(--dbt-nav-target);height:var(--dbt-nav-target);flex:none;border:0;border-radius:var(--dsw-radius-md);display:inline-flex;align-items:center;justify-content:center;padding:0;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}
1206
+ .dbt-panel-row:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
1207
+ .dbt-panel-row[aria-current="page"]{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-brand-primary)}
1208
+ .dbt-panel-glyph{display:inline-flex;align-items:center;justify-content:center}
1209
+ .dbt-panel-entry{position:relative;display:inline-flex;flex:none}
1210
+ .dbt-panel-unpin{position:absolute;top:-3px;right:-3px;z-index:1;width:var(--dbt-unpin-target);height:var(--dbt-unpin-target);border:0;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;padding:0;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);cursor:pointer;opacity:0;pointer-events:none}
1211
+ .dbt-panel-entry:is(:hover,:focus-within) .dbt-panel-unpin{opacity:1;pointer-events:auto}
1212
+ .dbt-panel-unpin:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}
1213
+ .dbt-panel-more-anchor{display:inline-flex;flex:none}
1214
+ .dbt-panel-more-menu{min-width:220px}
1215
+ .dbt-sidebar[data-wide="false"] .dbt-panel-list{flex-direction:column;flex-wrap:nowrap;max-height:30%}
1216
+ .dbt-sidebar[data-wide="false"] .dbt-panel-row{width:var(--dbt-rail-target);height:var(--dbt-rail-target)}
1217
+ .dbt-panel-row:focus-visible,.dbt-panel-unpin:focus-visible,.dbt-new-session:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
1218
+ .dbt-workspaces{width:100%;min-height:0;display:flex;flex:1;overflow:hidden}
1219
+ .dbt-foot{width:100%;display:flex;flex-direction:column;gap:4px;flex:none}
1220
+ .dbt-footer-actions,.dbt-settings{width:100%}
1221
+ .dbt-sidebar[data-wide="false"] .dbt-footer-actions,.dbt-sidebar[data-wide="false"] .dbt-settings{display:flex;justify-content:center}
1222
+ .dbt-visually-hidden{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
1223
+ .dbt-region-shell{width:100%;min-height:0;display:flex;flex:1;flex-direction:column;overflow:hidden}
1224
+ .dbt-view-tabs{display:grid;grid-template-columns:1fr 1fr;gap:2px;padding:0 4px;border-bottom:1px solid var(--dsw-alias-border-l1);background:transparent;flex:none}
1225
+ .dbt-view-tab{height:30px;margin:0 0 -1px;border:1px solid transparent;border-radius:8px 8px 0 0;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font:inherit;font-size:12px}
1226
+ .dbt-view-tab:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}
1227
+ .dbt-view-tab[aria-selected="true"]{border-color:var(--dsw-alias-border-l1);border-bottom-color:var(--dsw-alias-bg-layer-2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:600;box-shadow:none}
1228
+ .dbt-region-panel{min-width:0;min-height:0;display:flex;flex-direction:column;flex:1;overflow:hidden;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-top:0;border-radius:0 0 10px 10px;padding:6px}
1229
+ .dbt-task-view{min-height:0;display:flex;flex:1;flex-direction:column;gap:7px;overflow:hidden}
1230
+ .dbt-task-list{min-height:0;flex:1;overflow-y:auto;overflow-x:hidden;display:grid;grid-template-columns:minmax(0,1fr);grid-auto-rows:max-content;align-content:start;align-items:start;gap:7px;padding:2px}
1231
+ .dbt-task-list[data-columns="2"]{display:flex;align-items:flex-start}
1232
+ .dbt-task-column{min-width:0;flex:1;display:flex;flex-direction:column;gap:7px}
1233
+ .dbt-task-card{--dbt-task-tone:var(--dsw-alias-label-primary);--dbt-task-bg:var(--dsw-alias-bg-layer-1);--dbt-unpin-color:color-mix(in srgb,var(--dbt-task-bg) 80%,var(--dbt-task-tone));--dbt-task-border:color-mix(in srgb,currentColor 10%,transparent);--dbt-task-border-selected:color-mix(in srgb,var(--dsw-alias-brand-primary) 72%,var(--dsw-alias-label-primary));position:relative;box-sizing:border-box;width:100%;min-width:0;border:1px solid var(--dbt-task-border);border-radius:11px;background:var(--dbt-task-bg);padding:9px;display:flex;flex-direction:column;gap:8px;cursor:pointer;box-shadow:none}
1234
+ .dbt-task-card[data-state="idle"]{--dbt-task-border:color-mix(in srgb,var(--dsw-alias-label-primary) 12%,transparent);--dbt-task-border-selected:color-mix(in srgb,var(--dsw-alias-label-primary) 34%,transparent);--dbt-task-bg:color-mix(in srgb,var(--dsw-alias-label-primary) 3%,var(--dsw-alias-bg-layer-1))}
1235
+ .dbt-task-card[data-state="running"]{--dbt-task-tone:var(--dsw-alias-success,#1f9d62);--dbt-task-border:color-mix(in srgb,var(--dsw-alias-success,#1f9d62) 24%,transparent);--dbt-task-border-selected:color-mix(in srgb,var(--dsw-alias-success,#1f9d62) 58%,transparent);--dbt-task-bg:color-mix(in srgb,var(--dsw-alias-success,#1f9d62) 12%,var(--dsw-alias-bg-layer-1))}
1236
+ .dbt-task-card[data-state="block"]{--dbt-block-yellow:#d2aa00;--dbt-task-tone:var(--dbt-block-yellow);--dbt-task-border:color-mix(in srgb,var(--dbt-block-yellow) 34%,transparent);--dbt-task-border-selected:color-mix(in srgb,var(--dbt-block-yellow) 72%,var(--dsw-alias-label-primary));--dbt-task-bg:color-mix(in srgb,var(--dbt-block-yellow) 18%,var(--dsw-alias-bg-layer-1))}
1237
+ .dbt-task-card[data-current="true"]{border-color:var(--dbt-task-border-selected);box-shadow:none;--dbt-task-bg:color-mix(in srgb,var(--dsw-alias-label-primary) 8%,var(--dsw-alias-bg-layer-1))}
1238
+ .dbt-task-card[data-current="true"][data-state="running"]{--dbt-task-bg:color-mix(in srgb,var(--dsw-alias-success,#1f9d62) 17%,var(--dsw-alias-bg-layer-1))}
1239
+ .dbt-task-card[data-current="true"][data-state="block"]{--dbt-task-bg:color-mix(in srgb,var(--dbt-block-yellow) 23%,var(--dsw-alias-bg-layer-1))}
1240
+ .dbt-task-card[data-execution-warning]{--dbt-task-tone:var(--dsw-alias-state-error-primary);--dbt-task-border:color-mix(in srgb,var(--dsw-alias-state-error-primary) 24%,transparent);--dbt-task-border-selected:color-mix(in srgb,var(--dsw-alias-state-error-primary) 58%,transparent);--dbt-task-bg:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,var(--dsw-alias-bg-layer-1))}
1241
+ .dbt-task-card[data-current="true"][data-execution-warning]{--dbt-task-bg:color-mix(in srgb,var(--dsw-alias-state-error-primary) 17%,var(--dsw-alias-bg-layer-1))}
1242
+ .dbt-task-error-icon{flex:none;color:var(--dsw-alias-state-error-primary)}
1243
+ .dbt-task-card[data-drop="before"]:before,.dbt-task-card[data-drop="after"]:after{content:"";position:absolute;z-index:2;left:8px;right:8px;height:2px;border-radius:2px;background:var(--dsw-alias-brand-primary);pointer-events:none}
1244
+ .dbt-task-card[data-drop="before"]:before{top:1px}.dbt-task-card[data-drop="after"]:after{bottom:1px}
1245
+ .dbt-task-list[data-columns="2"] .dbt-task-card[data-drop="before"]:before,.dbt-task-list[data-columns="2"] .dbt-task-card[data-drop="after"]:after{top:8px;bottom:8px;width:2px;height:auto}
1246
+ .dbt-task-list[data-columns="2"] .dbt-task-card[data-drop="before"]:before{left:1px;right:auto}.dbt-task-list[data-columns="2"] .dbt-task-card[data-drop="after"]:after{left:auto;right:1px}
1247
+ .dbt-task-title-row{display:flex;align-items:center;gap:7px;min-width:0}
1248
+ .dbt-task-open{min-width:0;flex:1;border:0;background:transparent;color:inherit;text-align:left;cursor:pointer;padding:0}
1249
+ .dbt-task-project{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px;font-weight:680;line-height:19px}
1250
+ .dbt-task-session-line{display:flex;align-items:center;gap:5px;min-width:0;margin-top:1px}
1251
+ .dbt-task-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;color:var(--dsw-alias-label-secondary)}
1252
+ .dbt-task-rename{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border:0;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;text-align:left;cursor:pointer;padding:0}
1253
+ .dbt-task-list[data-title-emphasis="task"] .dbt-task-project{font-size:11px;font-weight:400;line-height:16px;color:var(--dsw-alias-label-secondary)}
1254
+ .dbt-task-list[data-title-emphasis="task"] .dbt-task-rename{font-size:14px;font-weight:680;line-height:19px;color:var(--dsw-alias-label-primary)}
1255
+ .dbt-task-rename:hover{text-decoration:underline;color:var(--dsw-alias-label-primary)}
1256
+ .dbt-task-card[data-current="true"] .dbt-task-rename{color:var(--dsw-alias-brand-primary)}
1257
+ .dbt-task-list[data-title-emphasis="task"] .dbt-task-card[data-current="true"] .dbt-task-rename{color:var(--dsw-alias-brand-primary)}
1258
+ .dbt-continue-shell,.dbt-continue-form{cursor:text}
1259
+ .dbt-continue-form{box-sizing:border-box;display:flex;flex-direction:column;min-width:0;width:100%;border:1px solid var(--dsw-alias-border-l1);border-radius:18px;background:var(--dsw-specific-input-major);box-shadow:var(--dsw-elevation-soft)}
1260
+ .dbt-continue-form:focus-within{border-color:var(--dsw-alias-brand-primary)}
1261
+ .dbt-continue-form .dbt-action-input{min-width:0;min-height:32px;max-height:120px;resize:none;overflow-y:hidden;padding:7px 11px 5px;font-size:12px;line-height:18px;background:transparent;border:0;border-radius:18px 18px 0 0;outline:none}
1262
+ .dbt-continue-actions{box-sizing:border-box;display:flex;justify-content:space-between;align-items:center;min-height:36px;padding:1px 6px 6px}
1263
+ .dbt-continue-add{display:grid;place-items:center;flex:none;width:27px;height:27px;padding:0;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}
1264
+ .dbt-continue-add:hover,.dbt-continue-add[aria-expanded="true"]{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
1265
+ .dbt-continue-add:disabled{opacity:.4;cursor:default}
1266
+ .dbt-continue-add:focus-visible,.dbt-skill-picker button:focus-visible,.dbt-skill-picker input:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
1267
+ .dbt-skill-picker{margin:0 7px 4px;padding:7px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);cursor:default}
1268
+ .dbt-skill-picker input{box-sizing:border-box;width:100%;height:29px;padding:4px 7px;border:1px solid var(--dsw-alias-border-l1);border-radius:6px;background:var(--dsw-specific-input-major);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px}
1269
+ .dbt-skill-results{max-height:180px;overflow-y:auto;margin-top:5px;display:flex;flex-direction:column;gap:2px}
1270
+ .dbt-skill-picker button{min-width:0;display:flex;flex-direction:column;gap:1px;width:100%;padding:5px 7px;border:0;border-radius:6px;text-align:left;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;cursor:pointer}
1271
+ .dbt-skill-picker button:hover{background:var(--dsw-alias-interactive-bg-hover)}
1272
+ .dbt-skill-picker small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%;color:var(--dsw-alias-label-tertiary);font-size:11px}
1273
+ .dbt-skill-status{padding:6px;color:var(--dsw-alias-label-secondary);font-size:11px}
1274
+ .dbt-continue-send{corner-shape:round;flex:none;display:grid;place-items:center;box-sizing:border-box;width:28px;height:28px;padding:0;border:0;border-radius:999px;background:var(--dsw-alias-button-info-fill);color:#fff;cursor:pointer;transition:background-color .1s}
1275
+ .dbt-continue-send:hover:not(:disabled){background:var(--dsw-alias-button-info-hover)}
1276
+ .dbt-continue-send:disabled{opacity:.4;cursor:default}
1277
+ .dbt-continue-send:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
1278
+ .dbt-continue-error{margin-top:-4px}
1279
+ .dbt-task-time{flex:none;font-size:10px;color:var(--dsw-alias-label-tertiary)}
1280
+ .dbt-project-icon{width:30px;height:30px;display:inline-flex;align-items:center;justify-content:center;flex:none;border:0;border-radius:8px;background:var(--dsw-alias-bg-layer-2);cursor:pointer}
1281
+ .dbt-project-icon:disabled{cursor:default;opacity:.72}
1282
+ .dbt-task-unpin{width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;flex:none;padding:0;border:1px solid transparent;border-radius:5px;background:transparent;color:var(--dbt-unpin-color);cursor:pointer;opacity:0;pointer-events:none;transition:border-color .12s ease}
1283
+ .dbt-task-unpin svg{width:14px;height:14px;flex:none}
1284
+ .dbt-task-card:hover .dbt-task-unpin,.dbt-task-card:focus-within .dbt-task-unpin,.dbt-task-unpin:focus-visible{opacity:1;pointer-events:auto}
1285
+ .dbt-task-unpin:hover{border-color:color-mix(in srgb,var(--dbt-unpin-color) 40%,transparent)}
1286
+ .dbt-task-unpin:disabled{cursor:default;opacity:.45}
1287
+ .dbt-task-projection-bar{display:flex;align-items:center;gap:5px}
1288
+ .dbt-projection-toggle{height:27px;min-width:34px;display:inline-flex;align-items:center;justify-content:center;gap:4px;border:0;border-radius:7px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);cursor:pointer}
1289
+ .dbt-projection-toggle:hover,.dbt-projection-toggle[aria-expanded="true"]{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}
1290
+ .dbt-projection-toggle:disabled{cursor:default;opacity:.38;color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-layer-2)}
1291
+ .dbt-todo-count{font-size:10px;font-variant-numeric:tabular-nums}
1292
+ .dbt-projection-details{display:flex;flex-direction:column;gap:6px;padding-top:1px}
1293
+ .dbt-projection-detail{display:grid;grid-template-columns:18px minmax(0,1fr);gap:6px;align-items:start;border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-bg-layer-2) 86%,transparent);padding:7px;font-size:var(--dbt-projection-font-size,13px);line-height:1.5}
1294
+ .dbt-projection-detail.dbt-final-detail{display:block;background:var(--dsw-alias-bg-layer-1);padding:10px;transition:background-color .34s ease}
1295
+ .dbt-projection-detail.dbt-final-detail[data-feedback="pending"]{background:color-mix(in srgb,var(--dsw-alias-label-primary) 6%,var(--dsw-alias-bg-layer-1));transition-duration:.22s}
1296
+ @media (prefers-reduced-motion:reduce){.dbt-projection-detail.dbt-final-detail{transition:none}}
1297
+ .dbt-final-input-toggle{box-sizing:border-box;width:calc(100% + 6px);margin:-3px;padding:8px;border:1px solid transparent;border-radius:8px;cursor:pointer;outline:none}
1298
+ .dbt-final-input-toggle:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
1299
+ .dbt-projection-detail[data-tone="blocked"]{color:var(--dsw-alias-error,#d14343)}
1300
+ .dbt-projection-copy{min-width:0;overflow-wrap:anywhere}
1301
+ .dbt-final-copy{min-width:0;overflow-wrap:anywhere}
1302
+ /* Rebind the consumed Markdown shorthands locally: BODY-derived theme tokens do
1303
+ not recompute merely because a descendant changes the content-size input. */
1304
+ .dbt-final-copy,.dbt-question-surface{--dbt-text-size:var(--dbt-projection-font-size,13px);--dbt-secondary-size:min(calc(var(--dbt-text-size) - 1px),max(13px,calc(var(--dbt-text-size) - 2px)));--dsw-font-markdown-base:400 var(--dbt-text-size)/calc(var(--dbt-text-size) + 10px) var(--dsw-font-family);--dsw-font-markdown-base-strong:600 var(--dbt-text-size)/calc(var(--dbt-text-size) + 10px) var(--dsw-font-family);--dsw-font-markdown-h1:700 calc(var(--dbt-text-size) + 7px)/calc(var(--dbt-text-size) + 16px) var(--dsw-font-family);--dsw-font-markdown-h2:700 calc(var(--dbt-text-size) + 5px)/calc(var(--dbt-text-size) + 14px) var(--dsw-font-family);--dsw-font-markdown-h3:700 calc(var(--dbt-text-size) + 4px)/calc(var(--dbt-text-size) + 12px) var(--dsw-font-family);--dsw-font-markdown-h4:600 var(--dbt-text-size)/calc(var(--dbt-text-size) + 10px) var(--dsw-font-family);--dsw-font-markdown-table:400 var(--dbt-secondary-size)/calc(var(--dbt-secondary-size) + 9px) var(--dsw-font-family);--dsw-font-markdown-table-head:500 var(--dbt-secondary-size)/calc(var(--dbt-secondary-size) + 9px) var(--dsw-font-family);--dsw-font-markdown-code:400 calc(var(--dbt-text-size) * 11 / 14)/calc(var(--dbt-text-size) * 19 / 14) var(--ds-font-family-code);--dsw-font-markdown-code-block:400 calc(var(--dbt-text-size) * 11 / 14)/calc(var(--dbt-text-size) * 19 / 14) var(--ds-font-family-code)}
1305
+ .dbt-final-copy .md-code-block,.dbt-question-surface .md-code-block{--dsl-code-block-banner-font:400 calc(var(--dbt-text-size) * 11 / 14)/calc(var(--dbt-text-size) * 18 / 14) var(--ds-font-family-code)}
1306
+ .dbt-final-copy [data-code-block-banner] > :first-child,.dbt-question-surface [data-code-block-banner] > :first-child{font-size:inherit;line-height:inherit}
1307
+ .dbt-final-copy pre,.dbt-final-copy table{max-width:100%;overflow-x:auto}
1308
+ .dbt-final-copy img{max-width:100%;height:auto}
1309
+ .dbt-final-meta{margin-top:5px;color:var(--dsw-alias-label-tertiary);font-size:10px}
1310
+ .dbt-final-retry{margin-top:5px;border:0;border-radius:6px;padding:3px 7px;background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);cursor:pointer}
1311
+ .dbt-question-surface{min-width:0;max-width:100%;overflow:hidden;font-size:var(--dbt-projection-font-size,13px);line-height:1.5}
1312
+ .dbt-question-notice{padding:6px 8px;color:var(--dsw-alias-warning,#a26900);font:inherit}
1313
+ .dbt-question-surface *{min-width:0}
1314
+ .dbt-task-answer{align-self:flex-start;margin:2px 0 0;padding:3px 10px;border-radius:6px;cursor:pointer;font:inherit;font-size:var(--dbt-projection-font-size,13px);line-height:1.5;color:var(--dsw-alias-label-primary);background:color-mix(in srgb,var(--dbt-block-yellow,#d2aa00) 18%,transparent);border:1px solid color-mix(in srgb,var(--dbt-block-yellow,#d2aa00) 45%,transparent)}
1315
+ .dbt-task-answer:hover{background:color-mix(in srgb,var(--dbt-block-yellow,#d2aa00) 28%,transparent)}
1316
+ .dbt-task-answer:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
1317
+ .dbt-todo-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:5px}
1318
+ .dbt-todo-item{display:grid;grid-template-columns:16px minmax(0,1fr);gap:5px;align-items:start}
1319
+ .dbt-todo-glyph{display:grid;place-items:center;flex:none;width:16px;height:16px}
1320
+ .dbt-task-empty,.dbt-task-warning{padding:10px;border-radius:9px;font-size:11px;color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-layer-2)}
1321
+ .dbt-task-warning{padding:6px 8px;color:var(--dsw-alias-warning,#a26900)}
1322
+ .dbt-dialog-backdrop{position:fixed;inset:0;z-index:1000;display:grid;place-items:center;background:color-mix(in srgb,#000 38%,transparent);padding:20px}
1323
+ .dbt-dialog{width:min(680px,92vw);max-height:min(720px,90vh);overflow:auto;border-radius:14px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);box-shadow:0 18px 60px rgba(0,0,0,.28);padding:18px;display:flex;flex-direction:column;gap:12px}
1324
+ .dbt-dialog h2{font-size:16px;margin:0}
1325
+ .dbt-dialog input{height:34px;border:1px solid color-mix(in srgb,currentColor 18%,transparent);border-radius:8px;background:var(--dsw-alias-bg-layer-2);color:inherit;padding:0 10px}
1326
+ .dbt-action-input{box-sizing:border-box;width:100%;min-height:92px;resize:vertical;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);padding:9px 10px;font:inherit}
1327
+ .dbt-action-input.dbt-rename-input{min-height:44px;height:44px;border:.5px solid var(--dsw-alias-border-l4);border-radius:22px;background:transparent;padding:7px 14px;font-size:14px;font-weight:400;line-height:22px;resize:none}
1328
+ .dbt-icon-grid{display:grid;grid-template-columns:repeat(8,minmax(0,1fr));gap:6px;max-height:360px;overflow:auto}
1329
+ .dbt-icon-choice{aspect-ratio:1;border:1px solid color-mix(in srgb,currentColor 15%,transparent);border-radius:7px;background:transparent;color:inherit;cursor:pointer;display:grid;place-items:center}
1330
+ .dbt-icon-choice[data-selected="true"]{border-color:var(--dsw-alias-brand-primary);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 12%,transparent)}
1331
+ .dbt-color-row{display:flex;flex-wrap:wrap;gap:7px}
1332
+ .dbt-color-choice{width:25px;height:25px;border-radius:50%;border:2px solid transparent;cursor:pointer}
1333
+ .dbt-color-choice[data-selected="true"]{outline:2px solid currentColor;outline-offset:2px}
1334
+ .dbt-dialog-actions{display:flex;justify-content:flex-end;gap:8px}
1335
+ .dbt-dialog-actions button{height:32px;border:0;border-radius:8px;padding:0 12px;cursor:pointer}
1336
+ .dbt-dialog-save{background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-static-white,#fff)}
1337
+ .dbt-settings-section{width:min(680px,100%);display:flex;flex-direction:column;gap:18px;color:var(--dsw-alias-label-primary)}
1338
+ .dbt-settings-heading{display:flex;flex-direction:column;gap:5px}.dbt-settings-heading h2{margin:0;font-size:18px;line-height:1.3}.dbt-settings-heading p,.dbt-settings-hint{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5}
1339
+ .dbt-settings-group{display:flex;flex-direction:column;gap:10px}.dbt-settings-group-title{font-size:14px;font-weight:650}
1340
+ .dbt-settings-card{display:flex;flex-direction:column;gap:10px;border:1px solid color-mix(in srgb,var(--dsw-alias-label-primary) 10%,transparent);border-radius:12px;background:var(--dsw-alias-bg-layer-1);padding:14px}
1341
+ .dbt-settings-font-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center}.dbt-settings-font-options{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:4px}.dbt-settings-font-option{height:32px;border:0;border-radius:7px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);cursor:pointer;font-variant-numeric:tabular-nums}.dbt-settings-font-option[aria-pressed="true"]{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-brand-primary);font-weight:650}.dbt-settings-font-option:disabled{cursor:default;opacity:.5}.dbt-settings-font-value{min-width:44px;text-align:right;font-variant-numeric:tabular-nums;font-weight:650}
1342
+ .dbt-settings-switch-row{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:30px}.dbt-settings-switch{position:relative;width:38px;height:22px;flex:none;border:0;border-radius:999px;background:var(--dsw-alias-bg-layer-3,var(--dsw-alias-interactive-bg-hover));cursor:pointer;padding:0}.dbt-settings-switch:after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-static-white,#fff);box-shadow:0 1px 3px rgba(0,0,0,.25);transition:transform .14s ease}.dbt-settings-switch[aria-checked="true"]{background:var(--dsw-alias-brand-primary)}.dbt-settings-switch[aria-checked="true"]:after{transform:translateX(16px)}.dbt-settings-switch:disabled{cursor:default;opacity:.5}
1343
+ .dbt-settings-segments{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:4px;padding:3px;border-radius:9px;background:var(--dsw-alias-bg-layer-2)}.dbt-settings-segments[data-options="2"]{grid-template-columns:repeat(2,minmax(0,1fr))}.dbt-settings-segment{height:32px;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}.dbt-settings-segment[aria-pressed="true"]{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);box-shadow:0 1px 3px rgba(0,0,0,.12)}.dbt-settings-segment:disabled{cursor:default;opacity:.5}
1344
+ .dbt-settings-status{border-radius:8px;padding:8px 10px;font-size:12px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary)}.dbt-settings-status[data-error="true"]{color:var(--dsw-alias-error,#d14343)}
1345
+ @media (prefers-reduced-motion:no-preference){.dbt-sidebar,.dbt-panel-row,.dbt-icon-button,.dbt-task-card{transition:background-color .15s ease,color .15s ease,border-color .15s ease}}
1346
+ `
1347
+
1348
+ function createSnapshotStore(initial) {
1349
+ let snapshot = initial
1350
+ const listeners = new Set()
1351
+ return {
1352
+ getSnapshot: () => snapshot,
1353
+ subscribe(listener) {
1354
+ listeners.add(listener)
1355
+ return () => listeners.delete(listener)
1356
+ },
1357
+ set(next) {
1358
+ snapshot = next
1359
+ for (const listener of listeners) listener()
1360
+ },
1361
+ }
1362
+ }
1363
+
1364
+ // Same transient owner/lifetime as native ui-sidebar: panel launchers are not Session Pins.
1365
+ function createPanelLauncherPins() {
1366
+ const source = createSnapshotStore(Object.freeze([]))
1367
+ return {
1368
+ source,
1369
+ setPinned(id, pinned) {
1370
+ if (typeof id !== 'string' || id === '' || typeof pinned !== 'boolean' || id === 'plugins') return
1371
+ const unpinned = source.getSnapshot()
1372
+ if (pinned !== unpinned.includes(id)) return
1373
+ source.set(Object.freeze(pinned ? unpinned.filter(value => value !== id) : [...unpinned, id]))
1374
+ },
1375
+ }
1376
+ }
1377
+
1378
+ function SidebarPanelIcon({ id, size, active, renderSlot }) {
1379
+ return renderSlot('sidebar.panellist', { size, active }, { only: id })
1380
+ }
1381
+
1382
+ function resolveLabel(label, fallback) {
1383
+ if (typeof label === 'function') return String(label())
1384
+ return typeof label === 'string' && label !== '' ? label : fallback
1385
+ }
1386
+
1387
+ function installStyles() {
1388
+ if (typeof document === 'undefined') return () => {}
1389
+ const previous = document.querySelector(`style[data-plugin-css="${STYLE_ID}"]`)
1390
+ if (previous !== null) return () => {}
1391
+ const tag = document.createElement('style')
1392
+ tag.dataset.plugin = name
1393
+ tag.dataset.pluginCss = STYLE_ID
1394
+ tag.textContent = css
1395
+ document.head.appendChild(tag)
1396
+ return () => tag.remove()
1397
+ }
1398
+
1399
+ function element(type, props, ...children) {
1400
+ return requireModule('react').createElement(type, props, ...children)
1401
+ }
1402
+
1403
+ const COLOR_HEX = Object.freeze({
1404
+ blue: '#3b82f6', cyan: '#06b6d4', green: '#22c55e', orange: '#f97316',
1405
+ pink: '#ec4899', purple: '#8b5cf6', red: '#ef4444', yellow: '#eab308',
1406
+ teal: '#14b8a6', indigo: '#6366f1', amber: '#f59e0b', slate: '#64748b', black: '#111827',
1407
+ })
1408
+
1409
+ function useStoredPreference(key, fallback, allowed) {
1410
+ const React = requireModule('react')
1411
+ const [value, setValue] = React.useState(() => {
1412
+ try {
1413
+ const stored = localStorage.getItem(key)
1414
+ return stored !== null && allowed.includes(stored) ? stored : fallback
1415
+ } catch {
1416
+ return fallback
1417
+ }
1418
+ })
1419
+ React.useEffect(() => {
1420
+ writeStorage(key, value)
1421
+ }, [key, value])
1422
+ return [value, setValue]
1423
+ }
1424
+
1425
+ function isProjectionExpanded(value, key, defaultExpanded) {
1426
+ return typeof value[key] === 'boolean' ? value[key] : defaultExpanded
1427
+ }
1428
+
1429
+ /** Native activity is lastPromptAt; cold cache misses may fall back to createdAt. */
1430
+ function isNewFinalIdleCycle(cycles, task) {
1431
+ if (!Number.isFinite(task.updatedAt) || task.updatedAt < 0) return false
1432
+ const consumed = cycles[task.id]
1433
+ // A cold/partial/older snapshot is not evidence of another completed cycle.
1434
+ // Never lower the persisted watermark and then reset again on hydration.
1435
+ return !Number.isFinite(consumed) || task.updatedAt > consumed
1436
+ }
1437
+
1438
+ function expandFinalForIdleCycles(cycles, idleTasks, value) {
1439
+ const nextCycles = { ...cycles }
1440
+ const nextExpanded = { ...value }
1441
+ let cyclesChanged = false
1442
+ let expandedChanged = false
1443
+ for (const task of idleTasks) {
1444
+ if (!isNewFinalIdleCycle(nextCycles, task)) continue
1445
+ nextCycles[task.id] = task.updatedAt
1446
+ cyclesChanged = true
1447
+ // Consume every new cycle, including the currently browsed Session. Its
1448
+ // suppression is remembered so navigation/refresh cannot open it later.
1449
+ const finalKey = `${task.id}:final`
1450
+ if (task.current === true) {
1451
+ // Explicit manual expansion wins; suppress only the automatic default.
1452
+ if (nextExpanded[finalKey] !== true && nextExpanded[finalKey] !== false) {
1453
+ nextExpanded[finalKey] = false
1454
+ expandedChanged = true
1455
+ }
1456
+ } else if (Object.hasOwn(nextExpanded, finalKey)) {
1457
+ delete nextExpanded[finalKey]
1458
+ expandedChanged = true
1459
+ }
1460
+ // Input remains subordinate to Final and starts a new disclosure cycle;
1461
+ // neither this reset nor suppression touches drafts, Todo, or Goal.
1462
+ const inputKey = `${task.id}:input`
1463
+ if (Object.hasOwn(nextExpanded, inputKey)) {
1464
+ delete nextExpanded[inputKey]
1465
+ expandedChanged = true
1466
+ }
1467
+ }
1468
+ return {
1469
+ cycles: cyclesChanged ? nextCycles : cycles,
1470
+ expanded: expandedChanged ? nextExpanded : value,
1471
+ }
1472
+ }
1473
+
1474
+ function reconcileGoalExpansionCycles(cycles, goals, endedSessionIds, value) {
1475
+ const nextCycles = { ...cycles }
1476
+ const nextExpanded = { ...value }
1477
+ let cyclesChanged = false
1478
+ let expandedChanged = false
1479
+ for (const sessionId of endedSessionIds) {
1480
+ if (Object.hasOwn(nextCycles, sessionId)) {
1481
+ delete nextCycles[sessionId]
1482
+ cyclesChanged = true
1483
+ }
1484
+ const key = `${sessionId}:goal`
1485
+ if (Object.hasOwn(nextExpanded, key)) {
1486
+ delete nextExpanded[key]
1487
+ expandedChanged = true
1488
+ }
1489
+ }
1490
+ for (const goal of goals) {
1491
+ if (nextCycles[goal.id] === goal.goalId) continue
1492
+ nextCycles[goal.id] = goal.goalId
1493
+ cyclesChanged = true
1494
+ const key = `${goal.id}:goal`
1495
+ if (!Object.hasOwn(nextExpanded, key)) continue
1496
+ delete nextExpanded[key]
1497
+ expandedChanged = true
1498
+ }
1499
+ return {
1500
+ cycles: cyclesChanged ? nextCycles : cycles,
1501
+ expanded: expandedChanged ? nextExpanded : value,
1502
+ }
1503
+ }
1504
+
1505
+ /** Progress-only changes do not start a new Todo disclosure lifecycle. */
1506
+ function todoExpansionCycle(task) {
1507
+ if (task.todos.availability === 'unknown' || task.goal.availability === 'unknown') return undefined
1508
+ return JSON.stringify([
1509
+ task.goal.availability === 'ready' ? task.goal.id : null,
1510
+ task.todos.availability === 'ready' ? task.todos.items.map((todo) => todo.content) : null,
1511
+ ])
1512
+ }
1513
+
1514
+ function reconcileTodoExpansionCycles(cycles, tasks, value) {
1515
+ const nextCycles = { ...cycles }
1516
+ const nextExpanded = { ...value }
1517
+ let cyclesChanged = false
1518
+ let expandedChanged = false
1519
+ for (const task of tasks) {
1520
+ const cycle = todoExpansionCycle(task)
1521
+ // A temporarily unloaded projection is not the end of its lifecycle.
1522
+ if (cycle === undefined || nextCycles[task.id] === cycle) continue
1523
+ nextCycles[task.id] = cycle
1524
+ cyclesChanged = true
1525
+ const key = `${task.id}:todo`
1526
+ if (!Object.hasOwn(nextExpanded, key)) continue
1527
+ delete nextExpanded[key]
1528
+ expandedChanged = true
1529
+ }
1530
+ return {
1531
+ cycles: cyclesChanged ? nextCycles : cycles,
1532
+ expanded: expandedChanged ? nextExpanded : value,
1533
+ }
1534
+ }
1535
+
1536
+ function parseFinalMessageCache(raw) {
1537
+ if (typeof raw !== 'string' || raw === '') return {}
1538
+ try {
1539
+ const parsed = JSON.parse(raw)
1540
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
1541
+ const states = {}
1542
+ for (const [sessionId, entry] of Object.entries(parsed).slice(-100)) {
1543
+ if (entry === null || typeof entry !== 'object' || entry.status !== 'ready') continue
1544
+ if (!['string', 'number'].includes(typeof entry.updatedAt)) continue
1545
+ const value = entry.value
1546
+ if (value === null || typeof value !== 'object' || value.sessionId !== sessionId) continue
1547
+ if (value.final !== null && (typeof value.final !== 'object' || typeof value.final.text !== 'string' || [...value.final.text].length > 4000)) continue
1548
+ states[sessionId] = { status: 'ready', updatedAt: entry.updatedAt, value }
1549
+ }
1550
+ return states
1551
+ } catch {
1552
+ return {}
1553
+ }
1554
+ }
1555
+
1556
+ function serializeFinalMessageCache(states) {
1557
+ const ready = Object.entries(states).filter(([, entry]) => entry?.status === 'ready').slice(-100)
1558
+ return JSON.stringify(Object.fromEntries(ready))
1559
+ }
1560
+
1561
+ function loadFinalMessageCache() {
1562
+ try { return parseFinalMessageCache(localStorage.getItem(FINAL_CACHE_KEY) ?? '') } catch { return {} }
1563
+ }
1564
+
1565
+ function parseExpansionRecord(raw) {
1566
+ try {
1567
+ const parsed = JSON.parse(raw ?? '{}')
1568
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
1569
+ return Object.fromEntries(Object.entries(parsed).filter(([entryKey, expanded]) => entryKey.length > 0 && typeof expanded === 'boolean').slice(-300))
1570
+ } catch {
1571
+ return {}
1572
+ }
1573
+ }
1574
+
1575
+ // Desktop's dsh-app renderer uses its isolated Electron user-data storage.
1576
+ // Host Config remains the owner of defaults; this is only per-client disclosure.
1577
+ // Accessing the localStorage property itself may throw, so guard the entire read.
1578
+ function loadStoredExpansion(key, legacyKey) {
1579
+ try {
1580
+ if (typeof localStorage === 'undefined') return {}
1581
+ const current = localStorage.getItem(key)
1582
+ if (current !== null) return parseExpansionRecord(current)
1583
+ if (legacyKey === undefined) return {}
1584
+ const legacy = parseExpansionRecord(localStorage.getItem(legacyKey))
1585
+ return Object.fromEntries(Object.entries(legacy).filter(([entryKey, expanded]) => entryKey.endsWith(':todo') && expanded === true))
1586
+ } catch {
1587
+ return {}
1588
+ }
1589
+ }
1590
+
1591
+ function useStoredExpansion(key, legacyKey) {
1592
+ const React = requireModule('react')
1593
+ const [value, setValue] = React.useState(() => loadStoredExpansion(key, legacyKey))
1594
+ React.useEffect(() => {
1595
+ writeStorage(key, JSON.stringify(value))
1596
+ }, [key, value])
1597
+ return [value, setValue]
1598
+ }
1599
+
1600
+ function useStoredRecord(key) {
1601
+ const React = requireModule('react')
1602
+ const [value, setValue] = React.useState(() => {
1603
+ try {
1604
+ if (typeof localStorage === 'undefined') return {}
1605
+ const parsed = JSON.parse(localStorage.getItem(key) ?? '{}')
1606
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
1607
+ // Keep at least as many cycle markers as bounded disclosure entries; a
1608
+ // missing marker must not reset a surviving manual choice on refresh.
1609
+ return Object.fromEntries(Object.entries(parsed).filter(([entryKey, cycle]) => entryKey.length > 0 && ['string', 'number'].includes(typeof cycle)).slice(-300))
1610
+ } catch {
1611
+ return {}
1612
+ }
1613
+ })
1614
+ React.useEffect(() => {
1615
+ writeStorage(key, JSON.stringify(value))
1616
+ }, [key, value])
1617
+ return [value, setValue]
1618
+ }
1619
+
1620
+ function useProjectAppearanceSnapshot() {
1621
+ const React = requireModule('react')
1622
+ const [state, setState] = React.useState({ status: 'loading', items: [], icons: [], colorKeys: [] })
1623
+ const [revision, setRevision] = React.useState(0)
1624
+ React.useEffect(() => {
1625
+ let alive = true
1626
+ loadProjectAppearances((url, options) => fetch(url, options)).then((next) => {
1627
+ if (alive) setState(next)
1628
+ })
1629
+ return () => { alive = false }
1630
+ }, [revision])
1631
+ React.useEffect(() => {
1632
+ if (typeof window === 'undefined') return
1633
+ const reload = () => setRevision((value) => value + 1)
1634
+ window.addEventListener('project-appearance-changed', reload)
1635
+ return () => window.removeEventListener('project-appearance-changed', reload)
1636
+ }, [])
1637
+ return state
1638
+ }
1639
+
1640
+ function renderCatalogIcon(icon, size = 18) {
1641
+ if (icon === undefined) return element(requireModule('@deepseek-ai/dsh-client-ui-primitives').IconFolderCloseRegular, { size })
1642
+ return element('svg', {
1643
+ viewBox: '0 0 24 24', width: size, height: size, fill: 'none', stroke: 'currentColor',
1644
+ strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true',
1645
+ }, ...icon.nodes.map(([tag, props], index) => element(tag, { ...props, key: `${tag}-${index}` })))
1646
+ }
1647
+
1648
+ /** Resolve verified 0.2 Regular exports lazily; never draw approximate replacement glyphs. */
1649
+ function nativeIcon(exportName, defaultSize = 14) {
1650
+ return function NativeIcon({ size = defaultSize, ...rest }) {
1651
+ const Icon = requireModule('@deepseek-ai/dsh-client-ui-primitives')[exportName]
1652
+ return element(Icon, { size, ...rest })
1653
+ }
1654
+ }
1655
+
1656
+ const IconPanelLeftOutlineRegular = nativeIcon('IconPanelLeftOutlineRegular', 16)
1657
+ const IconChecklistOutlineRegular = nativeIcon('IconChecklistOutlineRegular')
1658
+ const IconChevronDownOutlineRegular = nativeIcon('IconChevronDownOutlineRegular')
1659
+ const IconChevronRightOutlineRegular = nativeIcon('IconChevronRightOutlineRegular')
1660
+ const IconGoalOutlineRegular = nativeIcon('IconGoalOutlineRegular')
1661
+ const IconSparkleRegular = nativeIcon('IconSparkleRegular')
1662
+
1663
+ function renderOfficialPinIcon(size = 14) {
1664
+ const { IconPinFillRegular } = requireModule('@deepseek-ai/dsh-client-ui-primitives')
1665
+ return element(IconPinFillRegular, { size })
1666
+ }
1667
+
1668
+ function AppearanceDialog({ workspace, appearance, appearanceState, onClose, t }) {
1669
+ const React = requireModule('react')
1670
+ const [query, setQuery] = React.useState('')
1671
+ const [iconKey, setIconKey] = React.useState(appearance?.iconKey ?? 'folder')
1672
+ const [colorKey, setColorKey] = React.useState(appearance?.colorKey ?? 'blue')
1673
+ const [busy, setBusy] = React.useState(false)
1674
+ const [error, setError] = React.useState('')
1675
+ const colors = appearanceState.colorKeys.length > 0 ? appearanceState.colorKeys : Object.keys(COLOR_HEX)
1676
+ const needle = query.trim().toLowerCase()
1677
+ const icons = appearanceState.icons.filter((icon) => needle === '' || String(icon.label ?? icon.key).toLowerCase().includes(needle)).slice(0, 160)
1678
+
1679
+ const save = async () => {
1680
+ setBusy(true)
1681
+ setError('')
1682
+ try {
1683
+ const response = await fetch('/api/project-appearance', {
1684
+ method: 'PUT',
1685
+ headers: { 'content-type': 'application/json' },
1686
+ body: JSON.stringify({ workspaceId: workspace.workspaceId, iconKey, colorKey }),
1687
+ })
1688
+ if (!response.ok) throw new Error(`save failed: ${response.status}`)
1689
+ if (typeof window !== 'undefined') window.dispatchEvent(new Event('project-appearance-changed'))
1690
+ onClose()
1691
+ } catch (cause) {
1692
+ setError(String(cause?.message ?? cause))
1693
+ setBusy(false)
1694
+ }
1695
+ }
1696
+
1697
+ return element('div', { className: 'dbt-dialog-backdrop', role: 'presentation', onMouseDown: (event) => { if (event.target === event.currentTarget) onClose() } },
1698
+ element('section', { className: 'dbt-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-label': t('tasks.appearanceTitle') },
1699
+ element('h2', null, `${t('tasks.appearanceTitle')} · ${workspace.title}`),
1700
+ element('input', {
1701
+ value: query,
1702
+ placeholder: t('tasks.iconSearch'),
1703
+ 'aria-label': t('tasks.iconSearch'),
1704
+ onChange: (event) => setQuery(event.target.value),
1705
+ }),
1706
+ element('div', { className: 'dbt-icon-grid' }, ...icons.map((icon) => element('button', {
1707
+ key: icon.key,
1708
+ type: 'button',
1709
+ className: 'dbt-icon-choice',
1710
+ 'data-selected': String(icon.key === iconKey),
1711
+ title: icon.label ?? icon.key,
1712
+ 'aria-label': icon.label ?? icon.key,
1713
+ onClick: () => setIconKey(icon.key),
1714
+ }, renderCatalogIcon(icon, 21)))),
1715
+ element('div', { className: 'dbt-color-row', 'aria-label': 'Colors' }, ...colors.map((key) => element('button', {
1716
+ key,
1717
+ type: 'button',
1718
+ className: 'dbt-color-choice',
1719
+ 'data-selected': String(key === colorKey),
1720
+ style: { background: COLOR_HEX[key] ?? '#64748b', color: COLOR_HEX[key] ?? '#64748b' },
1721
+ title: key,
1722
+ 'aria-label': key,
1723
+ onClick: () => setColorKey(key),
1724
+ }))),
1725
+ error !== '' ? element('div', { className: 'dbt-task-warning', role: 'alert' }, error) : null,
1726
+ element('div', { className: 'dbt-dialog-actions' },
1727
+ element('button', { type: 'button', onClick: onClose, disabled: busy }, t('tasks.cancel')),
1728
+ element('button', { type: 'button', className: 'dbt-dialog-save', onClick: save, disabled: busy || icons.length === 0 }, t('tasks.save')))))
1729
+ }
1730
+
1731
+ function availabilityLabel(value, t) {
1732
+ if (value.availability === 'unknown') return t('tasks.unknown')
1733
+ if (value.availability === 'none') return t('tasks.none')
1734
+ return ''
1735
+ }
1736
+
1737
+ function compactRelativeTime(updatedAt, now, t, relativeTime) {
1738
+ const { unit, n } = relativeTime(updatedAt, now)
1739
+ return unit === 'now' ? t('time.now') : t(`time.${unit}`, { n })
1740
+ }
1741
+
1742
+ function resolveTaskContinueText(draft, defaultText) {
1743
+ return draft.trim() === '' ? defaultText : draft
1744
+ }
1745
+
1746
+ function taskContinuePlaceholder(draft, focused, defaultText) {
1747
+ return !focused && draft === '' ? defaultText : ''
1748
+ }
1749
+
1750
+ function TaskContinueInput({ sessionId, active, createTaskInput, onWarning, t }) {
1751
+ const React = requireModule('react')
1752
+ const [activated, setActivated] = React.useState(active)
1753
+ const [surface, setSurface] = React.useState(null)
1754
+ const [error, setError] = React.useState('')
1755
+ const [attempt, setAttempt] = React.useState(0)
1756
+ const warning = React.useRef(onWarning)
1757
+ warning.current = onWarning
1758
+ // Hidden, never-opened cards must not open/retain every background Session.
1759
+ React.useEffect(() => { if (active) setActivated(true) }, [active])
1760
+ React.useEffect(() => {
1761
+ if (!activated) return undefined
1762
+ const controller = new AbortController()
1763
+ let owned
1764
+ setSurface(null)
1765
+ setError('')
1766
+ Promise.resolve().then(() => createTaskInput(sessionId, {
1767
+ signal: controller.signal,
1768
+ onWarning: (text) => warning.current?.(text),
1769
+ })).then((handle) => {
1770
+ owned = handle
1771
+ if (controller.signal.aborted) handle.dispose()
1772
+ else setSurface(handle)
1773
+ }).catch((cause) => {
1774
+ if (!controller.signal.aborted) setError(String(cause?.message ?? cause))
1775
+ })
1776
+ return () => { controller.abort(); owned?.dispose() }
1777
+ }, [activated, sessionId, createTaskInput, attempt])
1778
+ return element('div', { className: 'dbt-continue-host', 'data-no-card-drag': true, draggable: false,
1779
+ onDragStart: (event) => event.preventDefault(),
1780
+ onClick: (event) => {
1781
+ if (event.target?.closest?.('button,a,input,textarea,select,[role="dialog"],[role="listbox"],[role="option"],[data-input-popup],[contenteditable="true"]')) return
1782
+ if (active) surface?.input.focus()
1783
+ },
1784
+ }, surface === null ? (error === ''
1785
+ ? activated ? element('div', { role: 'status' }, t('tasks.inputLoading')) : null
1786
+ : element('div', { className: 'dbt-task-warning', role: 'alert' }, error,
1787
+ element('button', { type: 'button', onClick: () => setAttempt(value => value + 1) }, t('tasks.retryInput'))))
1788
+ : element(surface.View, {
1789
+ className: 'dbt-continue-form', disabled: !active,
1790
+ placeholder: t('tasks.continueDefault'), defaultText: t('tasks.continueDefault'), submitLabel: t('tasks.send'),
1791
+ inputLabel: t('tasks.continue'), addLabel: t('tasks.add'), searchLabel: t('tasks.inputSearch'),
1792
+ emptyLabel: t('tasks.inputEmpty'), errorLabel: t('tasks.inputCatalogError'),
1793
+ }))
1794
+ }
1795
+
1796
+ function TaskQuestionCard({ target, createTaskQuestion, fontSize, revealQuestion, canRevealQuestion, t }) {
1797
+ const React = requireModule('react')
1798
+ const [handle, setHandle] = React.useState(null)
1799
+ const [error, setError] = React.useState('')
1800
+ const [attempt, setAttempt] = React.useState(0)
1801
+ React.useEffect(() => {
1802
+ const controller = new AbortController()
1803
+ let owned
1804
+ setHandle(null); setError('')
1805
+ Promise.resolve().then(() => createTaskQuestion({
1806
+ sessionId: target.sessionId, requestKey: target.requestKey, callId: target.callId, signal: controller.signal,
1807
+ })).then(value => {
1808
+ owned = value
1809
+ if (controller.signal.aborted) value.dispose()
1810
+ else setHandle(value)
1811
+ }).catch(cause => { if (!controller.signal.aborted) setError(String(cause?.message ?? cause)) })
1812
+ return () => { controller.abort(); owned?.dispose() }
1813
+ }, [target.sessionId, target.requestKey, target.callId, createTaskQuestion, attempt])
1814
+ const fallback = () => typeof target.callId === 'string' && canRevealQuestion()
1815
+ ? element('button', { type: 'button', className: 'dbt-task-answer', onClick: event => {
1816
+ event.stopPropagation()
1817
+ if (revealQuestion(target.sessionId, target.callId) === false) setError(t('tasks.answerFailed'))
1818
+ } }, t('tasks.answer')) : null
1819
+ if (handle !== null) return element(TaskQuestionView, { handle, fontSize, fallback, t, retry: () => setAttempt(value => value + 1) })
1820
+ if (error === '') return element('div', { role: 'status', className: 'dbt-question-notice' }, t('tasks.questionLoading'))
1821
+ return element('div', { role: 'alert', className: 'dbt-question-notice' }, error,
1822
+ element('button', { type: 'button', className: 'dbt-task-answer', onClick: () => setAttempt(value => value + 1) }, t('tasks.retryInput')), fallback())
1823
+ }
1824
+
1825
+ function TaskQuestionView({ handle, fontSize, fallback, t, retry }) {
1826
+ const React = requireModule('react')
1827
+ const state = React.useSyncExternalStore(listener => handle.state.subscribe(listener), () => handle.state.getSnapshot())
1828
+ if (state.phase === 'closed') return null
1829
+ if (state.phase === 'unsupported') return fallback()
1830
+ if (state.phase === 'error') return element('div', { className: 'dbt-question-notice', role: 'alert' }, state.message ?? t('tasks.answerFailed'),
1831
+ element('button', { type: 'button', className: 'dbt-task-answer', onClick: retry }, t('tasks.retryInput')), fallback())
1832
+ if (state.phase === 'waiting') return element('div', { className: 'dbt-question-notice', role: 'status' }, t('tasks.questionLoading'), fallback())
1833
+ return element(handle.View, { fontSize })
1834
+ }
1835
+
1836
+ function TaskActionDialog({ action, onClose, onSubmit, t }) {
1837
+ const React = requireModule('react')
1838
+ const { Modal, Button } = requireModule('@deepseek-ai/dsh-client-ui-primitives')
1839
+ const [draft, setDraft] = React.useState(action.title)
1840
+ const [busy, setBusy] = React.useState(false)
1841
+ const [error, setError] = React.useState('')
1842
+ const composing = React.useRef(false)
1843
+ const selectRenameInput = React.useCallback((node) => {
1844
+ if (node === null) return
1845
+ node.focus()
1846
+ node.select()
1847
+ }, [])
1848
+ const close = () => { if (!busy) onClose() }
1849
+ const submit = async () => {
1850
+ if (busy || draft.trim() === '') return
1851
+ setBusy(true)
1852
+ setError('')
1853
+ try { await onSubmit(action, draft); onClose() }
1854
+ catch (cause) { setError(String(cause?.message ?? cause)); setBusy(false) }
1855
+ }
1856
+ const inputProps = {
1857
+ className: 'dbt-action-input dbt-rename-input',
1858
+ value: draft,
1859
+ 'aria-label': t('tasks.rename'),
1860
+ ref: selectRenameInput,
1861
+ disabled: busy,
1862
+ onChange: (event) => { setDraft(event.target.value); setError('') },
1863
+ onCompositionStart: () => { composing.current = true },
1864
+ onCompositionEnd: () => { composing.current = false },
1865
+ onKeyDown: (event) => {
1866
+ if (event.key === 'Enter' && !event.shiftKey && !composing.current && !event.nativeEvent?.isComposing) {
1867
+ event.preventDefault()
1868
+ submit()
1869
+ }
1870
+ },
1871
+ }
1872
+ return element(Modal, {
1873
+ open: true,
1874
+ onClose: close,
1875
+ closeLabel: t('tasks.cancel'),
1876
+ title: t('tasks.rename'),
1877
+ footer: element(React.Fragment, null,
1878
+ element(Button, { variant: 'outline', disabled: busy, onClick: close }, t('tasks.cancel')),
1879
+ element(Button, { variant: 'primary', disabled: busy || draft.trim() === '', onClick: submit }, t('tasks.renameAction'))),
1880
+ },
1881
+ element('input', inputProps),
1882
+ error === '' ? null : element('div', { className: 'dbt-task-warning', role: 'alert' }, error))
1883
+ }
1884
+
1885
+ function shouldToggleFinalInput(target) {
1886
+ return !target?.closest?.('a')
1887
+ }
1888
+
1889
+ function isTaskCardFinalShortcutTarget(target) {
1890
+ return !target?.closest?.('[data-no-card-drag],button,a,input,textarea,select,[contenteditable="true"],[role="button"],[role="switch"]')
1891
+ }
1892
+
1893
+ /** A card gesture reads only the existing bounded, pinned-only Final endpoint. */
1894
+ function createCardFinalExpansionController({ getTask, getCached, isExpanded, readFinal, onReady, expand, collapse }) {
1895
+ const pending = new Map()
1896
+ let disposed = false
1897
+ const eligible = (task) => task?.session.status === 'idle'
1898
+ const validPayload = (id, value) => value?.sessionId === id &&
1899
+ (value.final === null || typeof value.final?.text === 'string')
1900
+ const hasFinal = (value) => typeof value.final?.text === 'string' && value.final.text.trim() !== ''
1901
+ const cancel = (id) => {
1902
+ const request = pending.get(id)
1903
+ if (request === undefined) return
1904
+ pending.delete(id)
1905
+ request.controller.abort()
1906
+ }
1907
+ const current = (request) => {
1908
+ const task = getTask(request.id)
1909
+ return !disposed && !request.controller.signal.aborted && pending.get(request.id) === request &&
1910
+ eligible(task) && task.updatedAt === request.updatedAt
1911
+ }
1912
+ return {
1913
+ request(id) {
1914
+ const task = getTask(id)
1915
+ if (disposed || !eligible(task)) return Promise.resolve(false)
1916
+ if (isExpanded(id)) {
1917
+ // Closing is immediate, even while a preview is loading or failed.
1918
+ // Invalidate an older read so its late response cannot reopen Final.
1919
+ cancel(id)
1920
+ collapse(id)
1921
+ return Promise.resolve(true)
1922
+ }
1923
+ const prior = pending.get(id)
1924
+ if (prior?.updatedAt === task.updatedAt) return prior.promise
1925
+ cancel(id)
1926
+ const cached = getCached(id)
1927
+ if (cached?.status === 'ready' && cached.updatedAt === task.updatedAt && validPayload(id, cached.value)) {
1928
+ if (!hasFinal(cached.value)) return Promise.resolve(false)
1929
+ expand(id)
1930
+ return Promise.resolve(true)
1931
+ }
1932
+ const request = { id, updatedAt: task.updatedAt, controller: new AbortController() }
1933
+ pending.set(id, request)
1934
+ request.promise = Promise.resolve().then(() => {
1935
+ if (!current(request)) return undefined
1936
+ return readFinal(request, request.controller.signal)
1937
+ }).then((value) => {
1938
+ if (!current(request) || !validPayload(id, value)) return false
1939
+ onReady(request, value)
1940
+ if (!hasFinal(value)) return false
1941
+ // This asynchronous path only opens; it never toggles a newer choice.
1942
+ expand(id)
1943
+ return true
1944
+ }).catch(() => false).finally(() => {
1945
+ if (pending.get(id) === request) pending.delete(id)
1946
+ })
1947
+ return request.promise
1948
+ },
1949
+ cancel,
1950
+ reconcile() {
1951
+ for (const [id, request] of pending) if (!current(request)) cancel(id)
1952
+ },
1953
+ dispose() {
1954
+ disposed = true
1955
+ for (const id of pending.keys()) cancel(id)
1956
+ },
1957
+ }
1958
+ }
1959
+
1960
+ const FINAL_DOUBLE_CLICK_WAIT_MS = 300
1961
+
1962
+ function createFinalClickController(schedule = (callback, ms) => window.setTimeout(callback, ms), cancelTimer = (timer) => window.clearTimeout(timer), feedback = () => {}) {
1963
+ const pending = new Map()
1964
+ const cancel = (id) => {
1965
+ const timer = pending.get(id)
1966
+ if (timer === undefined) return
1967
+ cancelTimer(timer)
1968
+ pending.delete(id)
1969
+ feedback(id, false)
1970
+ }
1971
+ return {
1972
+ click(id, action) {
1973
+ const prior = pending.get(id)
1974
+ if (prior !== undefined) cancelTimer(prior)
1975
+ else feedback(id, true)
1976
+ const timer = schedule(() => {
1977
+ pending.delete(id)
1978
+ feedback(id, false)
1979
+ action()
1980
+ }, FINAL_DOUBLE_CLICK_WAIT_MS)
1981
+ pending.set(id, timer)
1982
+ },
1983
+ doubleClick(id, action) {
1984
+ if (!pending.has(id)) return false
1985
+ cancel(id)
1986
+ action()
1987
+ return true
1988
+ },
1989
+ cancel,
1990
+ dispose() {
1991
+ for (const timer of pending.values()) cancelTimer(timer)
1992
+ pending.clear()
1993
+ },
1994
+ }
1995
+ }
1996
+
1997
+ function TaskSwimlane({ useSessions, useWorkspaces, useSessionStatus, useTaskConnection, usePanelInfo, useTaskPins, preferences, openSession, moveTaskBefore, unpinTask, revealQuestion, canRevealQuestion, findTaskQuestion, createTaskQuestion, twoColumns, t, submitTaskAction, createTaskInput }) {
1998
+ const React = requireModule('react')
1999
+ const {
2000
+ MarkdownText,
2001
+ StateDot,
2002
+ relativeTime,
2003
+ } = requireModule('@deepseek-ai/dsh-client-ui-primitives')
2004
+ const list = useSessions((snapshot) => snapshot)
2005
+ const panelActive = usePanelInfo((info) => info.activePanelId !== null)
2006
+ const workspaces = useWorkspaces((snapshot) => snapshot)
2007
+ // 0.2 carries the pending interaction on the Session status snapshot —
2008
+ // `Map<sessionId, { running, pendingInteraction, completionUnread }>`, reached
2009
+ // through the global standard prop every slot entry receives. The 0.1.5
2010
+ // pending-interaction hook was removed in 0.2, and reading it there yielded
2011
+ // `undefined` at render time: the TypeError retired this slot entry as an
2012
+ // abdicating crash and the whole sidebar column vanished.
2013
+ const sessionStatuses = useSessionStatus((snapshot) => snapshot)
2014
+ const connectionState = useTaskConnection((snapshot) => snapshot)
2015
+ const taskPins = useTaskPins((snapshot) => snapshot)
2016
+ const pendingInteractions = React.useMemo(() => {
2017
+ const next = new Map()
2018
+ // Metadata discovery is read-only; opening history is owned by an actual card.
2019
+ for (const id of taskPins.queue.sessionIds) {
2020
+ const target = findTaskQuestion?.(id)
2021
+ if (target !== undefined) next.set(id, { ...target, key: target.requestKey })
2022
+ }
2023
+ for (const [id, status] of sessionStatuses) {
2024
+ const pending = status?.pendingInteraction
2025
+ if (pending != null) next.set(id, pending)
2026
+ }
2027
+ return next
2028
+ }, [sessionStatuses, list, taskPins.queue.sessionIds, findTaskQuestion])
2029
+ const appearanceState = useProjectAppearanceSnapshot()
2030
+ const [editingWorkspaceId, setEditingWorkspaceId] = React.useState(null)
2031
+ const [taskAction, setTaskAction] = React.useState(null)
2032
+ const [storedExpanded, setExpanded] = useStoredExpansion('dsh-better-tasks.projection-expansion.v2', 'dsh-better-tasks.projection-expansion.v1')
2033
+ const [finalAutoCycles, setFinalAutoCycles] = useStoredRecord('dsh-better-tasks.final-auto-open-cycle.v1')
2034
+ const [goalAutoCycles, setGoalAutoCycles] = useStoredRecord('dsh-better-tasks.goal-auto-open-cycle.v1')
2035
+ const [todoAutoCycles, setTodoAutoCycles] = useStoredRecord('dsh-better-tasks.todo-expansion-cycle.v1')
2036
+ const [drag, setDrag] = React.useState(null)
2037
+ const dragClickGuard = React.useRef(null)
2038
+ const dragOriginGuard = React.useRef(null)
2039
+ const [pendingFinalIds, setPendingFinalIds] = React.useState(() => new Set())
2040
+ const finalClicks = React.useRef(null)
2041
+ if (finalClicks.current === null) finalClicks.current = createFinalClickController(undefined, undefined, (id, active) => {
2042
+ setPendingFinalIds((current) => {
2043
+ if (current.has(id) === active) return current
2044
+ const next = new Set(current)
2045
+ if (active) next.add(id)
2046
+ else next.delete(id)
2047
+ return next
2048
+ })
2049
+ })
2050
+ React.useEffect(() => () => finalClicks.current.dispose(), [])
2051
+ const [moveError, setMoveError] = React.useState('')
2052
+ const [actionWarning, setActionWarning] = React.useState('')
2053
+
2054
+ const tasks = derivePinnedTasks({
2055
+ list,
2056
+ panelActive,
2057
+ pinnedSessionIds: taskPins.queue.sessionIds,
2058
+ pendingInteractions,
2059
+ sessionStatuses,
2060
+ })
2061
+ const taskIds = tasks.map((task) => task.id)
2062
+ const goalTasks = tasks.filter((task) => task.goal.availability === 'ready' && typeof task.goal.id === 'string').map((task) => ({ id: task.id, goalId: task.goal.id }))
2063
+ const endedGoalTaskIds = tasks.filter((task) => task.goal.availability === 'none').map((task) => task.id)
2064
+ const goalCycleKey = `${goalTasks.map((task) => `${task.id}:${task.goalId}`).join('|')}::${endedGoalTaskIds.join('|')}`
2065
+ React.useEffect(() => {
2066
+ setGoalAutoCycles((cycles) => reconcileGoalExpansionCycles(cycles, goalTasks, endedGoalTaskIds, {}).cycles)
2067
+ setExpanded((value) => reconcileGoalExpansionCycles(goalAutoCycles, goalTasks, endedGoalTaskIds, value).expanded)
2068
+ }, [goalCycleKey])
2069
+ const todoCycleKey = JSON.stringify(tasks.map((task) => [task.id, todoExpansionCycle(task)]))
2070
+ React.useEffect(() => {
2071
+ setTodoAutoCycles((cycles) => reconcileTodoExpansionCycles(cycles, tasks, {}).cycles)
2072
+ // Functional updates preserve Goal/Final changes from this same commit.
2073
+ setExpanded((value) => reconcileTodoExpansionCycles(todoAutoCycles, tasks, value).expanded)
2074
+ }, [todoCycleKey])
2075
+ const idleTasks = tasks.filter((task) => task.session.status === 'idle').map((task) => ({ id: task.id, updatedAt: task.updatedAt, current: task.current }))
2076
+ const idleCycleKey = JSON.stringify(idleTasks)
2077
+ // Resolve BEFORE effects: display, lazy reads and manual gesture checks must
2078
+ // agree on current-cycle suppression, without a flash or an eager Final read.
2079
+ const expanded = expandFinalForIdleCycles(finalAutoCycles, idleTasks, storedExpanded).expanded
2080
+ React.useEffect(() => {
2081
+ const unseen = idleTasks.filter((task) => isNewFinalIdleCycle(finalAutoCycles, task))
2082
+ if (unseen.length === 0) return
2083
+ setFinalAutoCycles((cycles) => expandFinalForIdleCycles(cycles, unseen, {}).cycles)
2084
+ setExpanded((value) => expandFinalForIdleCycles(finalAutoCycles, unseen, value).expanded)
2085
+ }, [idleCycleKey])
2086
+ const [finalStates, setFinalStates] = React.useState(loadFinalMessageCache)
2087
+ React.useEffect(() => {
2088
+ writeStorage(FINAL_CACHE_KEY, serializeFinalMessageCache(finalStates))
2089
+ }, [finalStates])
2090
+ const finalRequests = tasks.filter((task) => {
2091
+ if (task.session.status !== 'idle' || !isProjectionExpanded(expanded, `${task.id}:final`, preferences.defaultFinalExpanded)) return false
2092
+ const cached = finalStates[task.id]
2093
+ return cached?.status !== 'ready' || cached.updatedAt !== task.updatedAt
2094
+ }).map((task) => ({ id: task.id, updatedAt: task.updatedAt }))
2095
+ const finalRequestKey = finalRequests.map((request) => `${request.id}:${request.updatedAt}`).join('|')
2096
+ const [finalRetry, setFinalRetry] = React.useState(0)
2097
+ React.useEffect(() => {
2098
+ if (finalRequests.length === 0) return undefined
2099
+ let disposed = false
2100
+ const controllers = finalRequests.map((request) => ({ request, controller: new AbortController() }))
2101
+ setFinalStates((current) => {
2102
+ const next = { ...current }
2103
+ for (const { request } of controllers) next[request.id] = { status: 'loading', updatedAt: request.updatedAt }
2104
+ return next
2105
+ })
2106
+ for (const { request, controller } of controllers) {
2107
+ fetch(`/api/better-tasks/final-message?sessionId=${encodeURIComponent(request.id)}`, { cache: 'no-store', signal: controller.signal })
2108
+ .then(async (response) => {
2109
+ const body = await response.json()
2110
+ if (!response.ok) throw new Error(body?.message ?? body?.code ?? `HTTP ${response.status}`)
2111
+ return body
2112
+ })
2113
+ .then((value) => {
2114
+ if (disposed) return
2115
+ setFinalStates((current) => current[request.id]?.updatedAt === request.updatedAt ? { ...current, [request.id]: { status: 'ready', updatedAt: request.updatedAt, value } } : current)
2116
+ })
2117
+ .catch((error) => {
2118
+ if (disposed || error?.name === 'AbortError') return
2119
+ setFinalStates((current) => current[request.id]?.updatedAt === request.updatedAt ? { ...current, [request.id]: { status: 'error', updatedAt: request.updatedAt, message: String(error?.message ?? error) } } : current)
2120
+ })
2121
+ }
2122
+ return () => {
2123
+ disposed = true
2124
+ for (const { controller } of controllers) controller.abort()
2125
+ }
2126
+ }, [finalRequestKey, finalRetry])
2127
+ const finalShortcutState = React.useRef(null)
2128
+ finalShortcutState.current = { tasks: new Map(tasks.map((task) => [task.id, task])), finalStates, expanded, preferences, finalAutoCycles }
2129
+ const cardFinalRequests = React.useRef(null)
2130
+ React.useEffect(() => {
2131
+ const changeFinalDisclosure = (id, visible) => {
2132
+ const snapshot = finalShortcutState.current
2133
+ const task = snapshot.tasks.get(id)
2134
+ if (task?.session.status !== 'idle') return
2135
+ finalClicks.current.cancel(id)
2136
+ const idleTask = { id, updatedAt: task.updatedAt, current: task.current }
2137
+ // Commit the same lifecycle and effective state the gesture observed;
2138
+ // otherwise a not-yet-persisted background cycle could erase manual close.
2139
+ setFinalAutoCycles((cycles) => expandFinalForIdleCycles(cycles, [idleTask], {}).cycles)
2140
+ setExpanded((current) => {
2141
+ const resolved = expandFinalForIdleCycles(snapshot.finalAutoCycles, [idleTask], current).expanded
2142
+ return resolved[`${id}:final`] === visible ? resolved : { ...resolved, [`${id}:final`]: visible }
2143
+ })
2144
+ }
2145
+ const operation = createCardFinalExpansionController({
2146
+ getTask: (id) => finalShortcutState.current.tasks.get(id),
2147
+ getCached: (id) => finalShortcutState.current.finalStates[id],
2148
+ isExpanded: (id) => isProjectionExpanded(finalShortcutState.current.expanded, `${id}:final`, finalShortcutState.current.preferences.defaultFinalExpanded),
2149
+ readFinal: async (request, signal) => {
2150
+ const response = await fetch(`/api/better-tasks/final-message?sessionId=${encodeURIComponent(request.id)}`, { cache: 'no-store', signal })
2151
+ const value = await response.json()
2152
+ if (!response.ok) throw new Error(value?.message ?? value?.code ?? `HTTP ${response.status}`)
2153
+ return value
2154
+ },
2155
+ onReady: (request, value) => setFinalStates((current) => {
2156
+ const task = finalShortcutState.current.tasks.get(request.id)
2157
+ if (task?.session.status !== 'idle' || task.updatedAt !== request.updatedAt) return current
2158
+ return { ...current, [request.id]: { status: 'ready', updatedAt: request.updatedAt, value } }
2159
+ }),
2160
+ expand: (id) => changeFinalDisclosure(id, true),
2161
+ collapse: (id) => changeFinalDisclosure(id, false),
2162
+ })
2163
+ cardFinalRequests.current = operation
2164
+ return () => {
2165
+ operation.dispose()
2166
+ if (cardFinalRequests.current === operation) cardFinalRequests.current = null
2167
+ }
2168
+ }, [])
2169
+ const finalShortcutKey = JSON.stringify(tasks.map((task) => [task.id, task.session.status, task.updatedAt]))
2170
+ React.useEffect(() => { cardFinalRequests.current?.reconcile() }, [finalShortcutKey])
2171
+ const workspaceBySession = indexWorkspacesBySession(workspaces.items)
2172
+ const appearanceByWorkspace = new Map(appearanceState.items.map((item) => [item.workspaceId, item]))
2173
+ const iconByKey = new Map(appearanceState.icons.map((icon) => [icon.key, icon]))
2174
+ const editingWorkspace = workspaces.items.find((workspace) => workspace.workspaceId === editingWorkspaceId)
2175
+ const pendingMoves = new Set(taskPins.pendingSessionIds)
2176
+ const now = Date.now()
2177
+ const toggleProjection = (sessionId, key, defaultExpanded) => {
2178
+ if (key === 'final') cardFinalRequests.current?.cancel(sessionId)
2179
+ const disclosureKey = `${sessionId}:${key}`
2180
+ setExpanded((current) => {
2181
+ const resolved = key === 'final' || key === 'input'
2182
+ ? expandFinalForIdleCycles(finalAutoCycles, idleTasks, current).expanded
2183
+ : current
2184
+ return { ...resolved, [disclosureKey]: !isProjectionExpanded(resolved, disclosureKey, defaultExpanded) }
2185
+ })
2186
+ }
2187
+ const commitDrop = (overId, half) => {
2188
+ if (drag === null || taskPins.writable !== true) return
2189
+ const sessionId = drag.sessionId
2190
+ const beforeSessionId = beforeIdForTaskDrop(taskIds, overId, half)
2191
+ setDrag(null)
2192
+ if (beforeSessionId === sessionId) return
2193
+ setMoveError('')
2194
+ moveTaskBefore(sessionId, beforeSessionId).catch((error) => setMoveError(String(error?.message ?? error)))
2195
+ }
2196
+
2197
+ return element('section', {
2198
+ className: 'dbt-task-view',
2199
+ 'aria-label': t('view.tasks'),
2200
+ style: { '--dbt-projection-font-size': `${preferences.detailFontSize}px` },
2201
+ },
2202
+ appearanceState.status === 'unavailable' ? element('div', { className: 'dbt-task-warning', role: 'status' }, t('tasks.appearanceUnavailable')) : null,
2203
+ taskPins.migration === 'initializing' ? element('div', { className: 'dbt-task-warning', role: 'status' }, t('tasks.orderRestoring')) : null,
2204
+ taskPins.migration === 'running' ? element('div', { className: 'dbt-task-warning', role: 'status' }, t('tasks.pinMigration')) : null,
2205
+ taskPins.error !== null || moveError !== '' ? element('div', { className: 'dbt-task-warning', role: 'status' }, moveError || taskPins.error) : null,
2206
+ actionWarning === '' ? null : element('div', { className: 'dbt-task-warning', role: 'alert' }, actionWarning),
2207
+ tasks.length === 0 ? element('div', { className: 'dbt-task-empty' }, t('tasks.empty')) :
2208
+ element('div', {
2209
+ className: 'dbt-task-list',
2210
+ 'data-columns': twoColumns ? '2' : '1',
2211
+ 'data-title-emphasis': preferences.titleEmphasis,
2212
+ onDragOver: (event) => {
2213
+ if (drag === null || event.target !== event.currentTarget) return
2214
+ event.preventDefault()
2215
+ const lastId = taskIds.at(-1)
2216
+ if (lastId !== undefined && (drag.overId !== lastId || drag.half !== 'after')) setDrag({ ...drag, overId: lastId, half: 'after' })
2217
+ },
2218
+ onDrop: (event) => {
2219
+ if (drag === null || event.target !== event.currentTarget) return
2220
+ event.preventDefault()
2221
+ const lastId = taskIds.at(-1)
2222
+ if (lastId !== undefined) commitDrop(lastId, 'after')
2223
+ },
2224
+ }, ...((cards) => twoColumns ? [
2225
+ element('div', { key: 'column-even', className: 'dbt-task-column', 'data-column': 'even' }, ...cards.filter((_, index) => index % 2 === 0)),
2226
+ element('div', { key: 'column-odd', className: 'dbt-task-column', 'data-column': 'odd' }, ...cards.filter((_, index) => index % 2 === 1)),
2227
+ ] : cards)(tasks.map((task) => {
2228
+ const workspace = workspaceBySession.get(task.id)
2229
+ const projectName = workspace?.title ?? task.cwd ?? t('tasks.unknown')
2230
+ const appearance = workspace === undefined ? undefined : appearanceByWorkspace.get(workspace.workspaceId)
2231
+ const icon = appearance === undefined ? undefined : iconByKey.get(appearance.iconKey)
2232
+ const iconColor = COLOR_HEX[appearance?.colorKey] ?? 'var(--dsw-alias-label-secondary)'
2233
+ const goalAvailable = task.goal.availability === 'ready'
2234
+ const todoAvailable = task.todos.availability === 'ready'
2235
+ const todoVisible = !todoAvailable || task.todos.total > 0
2236
+ const goalExpanded = goalAvailable && isProjectionExpanded(expanded, `${task.id}:goal`, preferences.defaultGoalExpanded)
2237
+ const todoExpanded = todoAvailable && task.todos.total > 0 && isProjectionExpanded(expanded, `${task.id}:todo`, preferences.defaultTodoExpanded)
2238
+ const finalExpanded = task.session.status === 'idle' && isProjectionExpanded(expanded, `${task.id}:final`, preferences.defaultFinalExpanded)
2239
+ const inputExpanded = finalExpanded && isProjectionExpanded(expanded, `${task.id}:input`, preferences.defaultInputExpanded)
2240
+ const finalState = finalStates[task.id]
2241
+ const pendingInteraction = pendingInteractions.get(task.id)
2242
+ const questionTarget = pendingInteraction?.kind === 'question' || pendingInteraction?.kind === 'plan-review'
2243
+ ? { sessionId: task.id, kind: pendingInteraction.kind,
2244
+ requestKey: pendingInteraction.key ?? pendingInteraction.requestKey, callId: pendingInteraction.callId }
2245
+ : undefined
2246
+ const answerCallId = questionTarget?.callId
2247
+ const inlineQuestion = questionTarget?.kind === 'question' &&
2248
+ (typeof questionTarget.requestKey === 'string' || typeof answerCallId === 'string')
2249
+ const answerable = inlineQuestion || typeof answerCallId === 'string' && canRevealQuestion()
2250
+ const goalLabel = task.goal.availability === 'ready' ? `${task.goal.phase}: ${task.goal.objective}` : availabilityLabel(task.goal, t)
2251
+ const todoLabel = task.todos.availability === 'ready' ? `${task.todos.completed}/${task.todos.total}` : availabilityLabel(task.todos, t)
2252
+ const executionWarning = taskExecutionWarning({
2253
+ ...list.byId[task.id],
2254
+ running: task.session.status === 'running',
2255
+ }, pendingInteraction, connectionState, list.phase === undefined || list.phase === 'ready')
2256
+ const warningLabel = executionWarning === null ? '' : t(`tasks.execution.${executionWarning.kind}`)
2257
+ const stateLabel = executionWarning === null
2258
+ ? (task.session.status === 'block' ? t('tasks.blockAria') : task.session.status === 'running' ? t('tasks.runningAria') : t('tasks.idleAria'))
2259
+ : [warningLabel, executionWarning.detail].filter(Boolean).join('\n')
2260
+ const dropHalf = drag?.overId === task.id ? drag.half : undefined
2261
+ const detailRows = []
2262
+ if (goalExpanded) detailRows.push(element('div', {
2263
+ key: 'goal',
2264
+ className: 'dbt-projection-detail',
2265
+ 'data-tone': task.goal.phase ?? task.goal.availability,
2266
+ title: goalLabel,
2267
+ 'aria-label': `${t('tasks.goal')}: ${goalLabel}`,
2268
+ },
2269
+ element(IconGoalOutlineRegular, { size: 14 }),
2270
+ element('div', { className: 'dbt-projection-copy' }, task.goal.availability === 'ready' ?
2271
+ element(React.Fragment, null,
2272
+ element('div', null, task.goal.objective),
2273
+ task.goal.blockedReason === undefined ? null : element('div', { title: task.goal.blockedReason.code }, task.goal.blockedReason.message)) :
2274
+ availabilityLabel(task.goal, t))))
2275
+ if (todoExpanded) detailRows.push(element('div', {
2276
+ key: 'todo',
2277
+ className: 'dbt-projection-detail',
2278
+ title: todoLabel,
2279
+ 'aria-label': `${t('tasks.todo')}: ${todoLabel}`,
2280
+ },
2281
+ element(IconChecklistOutlineRegular, { size: 14 }),
2282
+ element('div', { className: 'dbt-projection-copy' }, task.todos.availability === 'ready' ?
2283
+ element('ul', { className: 'dbt-todo-list' }, ...task.todos.items.map((todo, index) => element('li', {
2284
+ key: `${index}:${todo.content}`,
2285
+ className: 'dbt-todo-item',
2286
+ title: todo.status,
2287
+ },
2288
+ element('span', { className: 'dbt-todo-glyph', role: 'img', 'aria-label': t(todo.status === 'completed' ? 'tasks.todoCompleted' : todo.status === 'in_progress' ? 'tasks.todoInProgress' : 'tasks.todoPending') },
2289
+ element(StateDot, { state: todo.status === 'completed' ? 'done' : todo.status === 'in_progress' ? 'ongoing' : 'idle' })),
2290
+ element('span', null, todo.content)))) : availabilityLabel(task.todos, t))))
2291
+ if (finalExpanded) {
2292
+ let finalContent
2293
+ if (finalState === undefined || finalState.status === 'loading') {
2294
+ finalContent = t('tasks.finalLoading')
2295
+ } else if (finalState.status === 'error') {
2296
+ finalContent = t('tasks.finalError')
2297
+ } else if (finalState.value.final === null) {
2298
+ finalContent = t('tasks.finalNone')
2299
+ } else {
2300
+ finalContent = element(React.Fragment, null,
2301
+ element('div', { className: 'dbt-final-copy' }, element(MarkdownText, {
2302
+ text: finalState.value.final.text,
2303
+ labels: { code: { copyLabel: t('tasks.copyCode'), copiedLabel: t('tasks.copiedCode') }, footnotes: t('tasks.footnotes') },
2304
+ })),
2305
+ finalState.value.truncated ? element('div', { className: 'dbt-final-meta' }, `${t('tasks.finalTruncated')} · ${finalState.value.totalCodePoints}`) : null)
2306
+ }
2307
+ detailRows.push(element('div', {
2308
+ key: 'final',
2309
+ className: 'dbt-projection-detail dbt-final-detail',
2310
+ 'data-feedback': pendingFinalIds.has(task.id) ? 'pending' : undefined,
2311
+ 'data-tone': finalState?.status ?? 'loading',
2312
+ 'aria-label': t('tasks.final'),
2313
+ }, element('div', {
2314
+ className: 'dbt-final-input-toggle', role: 'button', tabIndex: 0,
2315
+ 'data-no-card-drag': true, draggable: false,
2316
+ 'aria-expanded': inputExpanded,
2317
+ 'aria-label': t(inputExpanded ? 'tasks.collapseInput' : 'tasks.expandInput'),
2318
+ onClickCapture: (event) => {
2319
+ if (!shouldToggleFinalInput(event.target)) return
2320
+ if (event.detail === 2 && finalClicks.current.doubleClick(task.id, () => toggleProjection(task.id, 'final', preferences.defaultFinalExpanded))) {
2321
+ event.preventDefault()
2322
+ return
2323
+ }
2324
+ if (event.detail > 2) return
2325
+ finalClicks.current.click(task.id, () => toggleProjection(task.id, 'input', preferences.defaultInputExpanded))
2326
+ },
2327
+ onKeyDown: (event) => {
2328
+ if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) return
2329
+ event.preventDefault()
2330
+ event.stopPropagation()
2331
+ finalClicks.current.cancel(task.id)
2332
+ toggleProjection(task.id, 'input', preferences.defaultInputExpanded)
2333
+ },
2334
+ }, element('div', { className: 'dbt-projection-copy' }, finalContent)),
2335
+ finalState?.status === 'error' ? element('button', {
2336
+ type: 'button', className: 'dbt-final-retry', 'data-no-card-drag': true, draggable: false,
2337
+ title: finalState.message,
2338
+ onClick: () => setFinalRetry((value) => value + 1),
2339
+ }, t('tasks.finalRetry')) : null))
2340
+ }
2341
+ return element('article', {
2342
+ key: task.id,
2343
+ className: 'dbt-task-card',
2344
+ 'data-session-id': task.id,
2345
+ 'data-current': String(task.current),
2346
+ 'data-state': task.session.status,
2347
+ 'data-execution-warning': executionWarning?.kind,
2348
+ 'data-drop': dropHalf,
2349
+ 'aria-label': `${projectName}: ${stateLabel}`,
2350
+ title: stateLabel,
2351
+ tabIndex: 0,
2352
+ draggable: taskPins.writable === true && !pendingMoves.has(task.id),
2353
+ onPointerDownCapture: (event) => {
2354
+ dragOriginGuard.current = event.target?.closest?.('[data-no-card-drag]') ? task.id : null
2355
+ },
2356
+ onKeyDown: (event) => {
2357
+ if (event.target !== event.currentTarget || !['Enter', ' '].includes(event.key)) return
2358
+ event.preventDefault()
2359
+ openSession(task.id)
2360
+ },
2361
+ onClick: (event) => {
2362
+ const fromControl = event.detail !== 0 && dragOriginGuard.current === task.id
2363
+ dragOriginGuard.current = null
2364
+ if (fromControl || event.target?.closest?.('[data-no-card-drag]') || dragClickGuard.current === task.id) return
2365
+ openSession(task.id)
2366
+ // The browser's second click is the double-click gesture. Keep the
2367
+ // first click immediate; do not re-open Final after its own body
2368
+ // double-click unmounts that control before a later dblclick event.
2369
+ if (event.detail === 2 && isTaskCardFinalShortcutTarget(event.target)) {
2370
+ void cardFinalRequests.current?.request(task.id)
2371
+ }
2372
+ },
2373
+ onDragStart: (event) => {
2374
+ if (taskPins.writable !== true) { event.preventDefault(); return }
2375
+ if (dragOriginGuard.current === task.id || event.target?.closest?.('[data-no-card-drag]')) {
2376
+ dragOriginGuard.current = null
2377
+ event.preventDefault()
2378
+ return
2379
+ }
2380
+ dragClickGuard.current = task.id
2381
+ event.dataTransfer.effectAllowed = 'move'
2382
+ event.dataTransfer.setData('text/plain', task.id)
2383
+ setDrag({ sessionId: task.id, overId: task.id, half: 'before' })
2384
+ },
2385
+ onDragEnd: () => {
2386
+ setDrag(null)
2387
+ window.setTimeout(() => { if (dragClickGuard.current === task.id) dragClickGuard.current = null }, 0)
2388
+ },
2389
+ onDragOver: (event) => {
2390
+ if (drag === null) return
2391
+ event.preventDefault()
2392
+ const rect = event.currentTarget.getBoundingClientRect()
2393
+ const half = twoColumns ? (event.clientX < rect.left + rect.width / 2 ? 'before' : 'after') : (event.clientY < rect.top + rect.height / 2 ? 'before' : 'after')
2394
+ if (drag.overId !== task.id || drag.half !== half) setDrag({ ...drag, overId: task.id, half })
2395
+ },
2396
+ onDrop: (event) => {
2397
+ event.preventDefault()
2398
+ if (drag !== null) commitDrop(task.id, drag.overId === task.id ? drag.half : 'before')
2399
+ },
2400
+ },
2401
+ element('div', { className: 'dbt-task-title-row' },
2402
+ element('button', {
2403
+ type: 'button',
2404
+ className: 'dbt-project-icon',
2405
+ style: { color: iconColor },
2406
+ 'data-no-card-drag': true,
2407
+ draggable: false,
2408
+ disabled: workspace === undefined || appearanceState.status !== 'ready',
2409
+ title: t('tasks.editAppearance'),
2410
+ 'aria-label': t('tasks.editAppearance'),
2411
+ onClick: () => setEditingWorkspaceId(workspace?.workspaceId ?? null),
2412
+ }, renderCatalogIcon(icon, 18)),
2413
+ element('div', { className: 'dbt-task-open' },
2414
+ element('span', { className: 'dbt-task-project' }, projectName),
2415
+ element('span', { className: 'dbt-task-session-line' },
2416
+ executionWarning !== null ? element('svg', {
2417
+ className: 'dbt-task-error-icon', width: 14, height: 14, viewBox: '0 0 24 24',
2418
+ fill: 'none', stroke: 'currentColor', strokeWidth: 2, 'aria-hidden': true,
2419
+ }, element('path', { d: 'M12 3 2 21h20L12 3Z', strokeLinejoin: 'round' }),
2420
+ element('path', { d: 'M12 9v5m0 3v1' })) :
2421
+ task.session.status === 'running' ? element(StateDot, { state: 'ongoing' }) : null,
2422
+ element('button', { type: 'button', className: 'dbt-task-rename', 'data-no-card-drag': true, draggable: false,
2423
+ title: t('tasks.rename'), 'aria-label': `${t('tasks.rename')}: ${task.title}`,
2424
+ onClick: () => setTaskAction({ kind: 'rename', id: task.id, title: task.title }),
2425
+ }, task.title),
2426
+ element('span', { className: 'dbt-task-time' }, compactRelativeTime(task.updatedAt, now, t, relativeTime)))),
2427
+ element('button', {
2428
+ type: 'button',
2429
+ className: 'dbt-task-unpin',
2430
+ 'data-no-card-drag': true,
2431
+ draggable: false,
2432
+ disabled: pendingMoves.has(task.id),
2433
+ title: t('tasks.unpin'),
2434
+ 'aria-label': t('tasks.unpin'),
2435
+ 'aria-pressed': true,
2436
+ onDragStart: (event) => event.preventDefault(),
2437
+ onClick: () => {
2438
+ setMoveError('')
2439
+ unpinTask(task.id).catch((error) => setMoveError(String(error?.message ?? error)))
2440
+ },
2441
+ }, renderOfficialPinIcon(14))),
2442
+ element('div', { className: 'dbt-task-projection-bar' },
2443
+ element('button', {
2444
+ type: 'button',
2445
+ className: 'dbt-projection-toggle',
2446
+ 'data-no-card-drag': true,
2447
+ draggable: false,
2448
+ disabled: !goalAvailable,
2449
+ 'aria-expanded': goalExpanded,
2450
+ 'aria-label': t(goalExpanded ? 'tasks.collapseGoal' : 'tasks.expandGoal'),
2451
+ title: goalLabel,
2452
+ onClick: () => { if (goalAvailable) toggleProjection(task.id, 'goal', preferences.defaultGoalExpanded) },
2453
+ }, element(IconGoalOutlineRegular, { size: 14 }), element(goalExpanded ? IconChevronDownOutlineRegular : IconChevronRightOutlineRegular, { size: 14 })),
2454
+ todoVisible ? element('button', {
2455
+ type: 'button',
2456
+ className: 'dbt-projection-toggle',
2457
+ 'data-no-card-drag': true,
2458
+ draggable: false,
2459
+ disabled: !todoAvailable,
2460
+ 'aria-expanded': todoExpanded,
2461
+ 'aria-label': t(todoExpanded ? 'tasks.collapseTodo' : 'tasks.expandTodo'),
2462
+ title: todoLabel,
2463
+ onClick: () => { if (todoAvailable) toggleProjection(task.id, 'todo', preferences.defaultTodoExpanded) },
2464
+ }, element(IconChecklistOutlineRegular, { size: 14 }), todoAvailable ? element('span', { className: 'dbt-todo-count' }, todoLabel) : null, element(todoExpanded ? IconChevronDownOutlineRegular : IconChevronRightOutlineRegular, { size: 14 })) : null,
2465
+ task.session.status !== 'idle' ? null : element('button', {
2466
+ type: 'button',
2467
+ className: 'dbt-projection-toggle',
2468
+ 'data-no-card-drag': true,
2469
+ draggable: false,
2470
+ 'aria-expanded': finalExpanded,
2471
+ 'aria-label': t(finalExpanded ? 'tasks.collapseFinal' : 'tasks.expandFinal'),
2472
+ title: t('tasks.final'),
2473
+ onClick: () => {
2474
+ finalClicks.current.cancel(task.id)
2475
+ toggleProjection(task.id, 'final', preferences.defaultFinalExpanded)
2476
+ },
2477
+ }, element(IconSparkleRegular, { size: 14 }), element(finalExpanded ? IconChevronDownOutlineRegular : IconChevronRightOutlineRegular, { size: 14 }))),
2478
+ detailRows.length === 0 ? null : element('div', { className: 'dbt-projection-details' }, ...detailRows),
2479
+ task.session.status !== 'idle' ? null : element('div', {
2480
+ className: 'dbt-continue-shell', hidden: !inputExpanded,
2481
+ 'data-no-card-drag': true, draggable: false,
2482
+ onDragStart: (event) => event.preventDefault(),
2483
+ }, element(TaskContinueInput, {
2484
+ sessionId: task.id,
2485
+ active: inputExpanded,
2486
+ createTaskInput,
2487
+ t,
2488
+ onWarning: (warning) => setActionWarning(`${t('tasks.goalResumeWarning')}: ${warning}`),
2489
+ })),
2490
+ answerable ? element('div', {
2491
+ className: 'dbt-question-surface',
2492
+ 'data-no-card-drag': true,
2493
+ draggable: false,
2494
+ onDragStart: (event) => event.preventDefault(),
2495
+ },
2496
+ inlineQuestion ? element(TaskQuestionCard, {
2497
+ key: `${task.id}:${questionTarget.requestKey ?? questionTarget.callId}`,
2498
+ target: questionTarget, createTaskQuestion, fontSize: preferences.detailFontSize,
2499
+ revealQuestion, canRevealQuestion, t,
2500
+ }) : element('button', {
2501
+ type: 'button',
2502
+ className: 'dbt-task-answer',
2503
+ title: t('tasks.answerHint'),
2504
+ onClick: (event) => {
2505
+ event.stopPropagation()
2506
+ const revealed = revealQuestion(task.id, answerCallId)
2507
+ // `null` is "this composition has no question service" (already
2508
+ // filtered out above); `false` is "the panel exists but would not
2509
+ // open". Only the latter is worth telling the user about.
2510
+ if (revealed === false) setActionWarning(t('tasks.answerFailed'))
2511
+ },
2512
+ }, t('tasks.answer'))) : null)
2513
+ }))),
2514
+ editingWorkspace !== undefined ? element(AppearanceDialog, {
2515
+ workspace: editingWorkspace,
2516
+ appearance: appearanceByWorkspace.get(editingWorkspace.workspaceId),
2517
+ appearanceState,
2518
+ onClose: () => setEditingWorkspaceId(null),
2519
+ t,
2520
+ }) : null,
2521
+ taskAction === null ? null : element(TaskActionDialog, {
2522
+ key: `${taskAction.kind}:${taskAction.id}`,
2523
+ action: taskAction,
2524
+ onClose: () => setTaskAction(null),
2525
+ onSubmit: (action, text) => {
2526
+ const current = tasks.find((item) => item.id === action.id)
2527
+ if (current === undefined || action.kind === 'continue' && current.session.status !== 'idle') throw new Error(t('tasks.actionUnavailable'))
2528
+ return submitTaskAction(action, text)
2529
+ },
2530
+ t,
2531
+ }))
2532
+ }
2533
+
2534
+ function SidebarRegion({ wide, collapsed, view, setView, twoColumns, preferences, toggleSidebar, renderSlot, useSessions, useWorkspaces, useSessionStatus, useTaskConnection, usePanelInfo, useTaskPins, openSession, moveTaskBefore, unpinTask, revealQuestion, canRevealQuestion, findTaskQuestion, createTaskQuestion, submitTaskAction, createTaskInput, t }) {
2535
+ if (!wide) {
2536
+ return renderSlot('sidebar.workspaces', { wide: false, expandSidebar: () => { if (collapsed) toggleSidebar() } })
2537
+ }
2538
+ return element('div', { className: 'dbt-region-shell' },
2539
+ element('div', { className: 'dbt-view-tabs', role: 'tablist', 'aria-label': 'Sidebar view' },
2540
+ element('button', { type: 'button', role: 'tab', className: 'dbt-view-tab', 'aria-selected': view === 'native', onClick: () => setView('native') }, t('view.native')),
2541
+ element('button', { type: 'button', role: 'tab', className: 'dbt-view-tab', 'aria-selected': view === 'tasks', onClick: () => setView('tasks') }, t('view.tasks'))),
2542
+ element('div', { className: 'dbt-region-panel', role: 'tabpanel' },
2543
+ view === 'native' ? renderSlot('sidebar.workspaces', { wide: true, expandSidebar: () => {} }) : element(TaskSwimlane, {
2544
+ useSessions,
2545
+ useWorkspaces,
2546
+ useSessionStatus, useTaskConnection,
2547
+ usePanelInfo,
2548
+ useTaskPins,
2549
+ openSession,
2550
+ moveTaskBefore,
2551
+ unpinTask,
2552
+ revealQuestion,
2553
+ canRevealQuestion,
2554
+ findTaskQuestion,
2555
+ createTaskQuestion,
2556
+ submitTaskAction,
2557
+ createTaskInput,
2558
+ twoColumns,
2559
+ preferences,
2560
+ t,
2561
+ })))
2562
+ }
2563
+
2564
+ function PanelRow({ panel, usePanelInfo, selectPanel, renderSlot, renderFactorySlot, canUnpin, unpinLabel, setPanelPinned }) {
2565
+ const { Tooltip, IconPinFillRegular, focusWithoutRing } = requireModule('@deepseek-ai/dsh-client-ui-primitives')
2566
+ const active = usePanelInfo((info) => info.activePanelId === panel.id)
2567
+ return element('div', { className: 'dbt-panel-entry' },
2568
+ element(Tooltip, { label: panel.label, delayMs: SIDEBAR_LAYOUT.tooltipDelayMs, side: 'bottom', portal: true,
2569
+ children: element('button', {
2570
+ type: 'button', className: 'dbt-panel-row',
2571
+ 'aria-label': panel.label, 'aria-current': active ? 'page' : undefined,
2572
+ onClick: () => selectPanel(panel.id),
2573
+ }, element('span', { className: 'dbt-panel-glyph', 'aria-hidden': 'true' },
2574
+ typeof renderFactorySlot === 'function'
2575
+ ? renderFactorySlot('sidebar.panel.icon', { id: panel.id, size: SIDEBAR_LAYOUT.iconSize, active })
2576
+ : renderSlot('sidebar.panellist', { size: SIDEBAR_LAYOUT.iconSize, active }, { only: panel.id }))) }),
2577
+ // Native More lists only UNPINNED entries. Keep a separate unpin affordance,
2578
+ // or the menu would have no way to acquire its first hidden launcher.
2579
+ canUnpin ? element(Tooltip, { label: unpinLabel, delayMs: SIDEBAR_LAYOUT.tooltipDelayMs, side: 'bottom', portal: true,
2580
+ children: element('button', { type: 'button', className: 'dbt-panel-unpin', 'aria-label': unpinLabel,
2581
+ onClick: (event) => {
2582
+ const more = event.currentTarget.closest('nav')?.querySelector('button[aria-haspopup="menu"]')
2583
+ if (more) { if (typeof focusWithoutRing === 'function') focusWithoutRing(more); else more.focus() }
2584
+ setPanelPinned(panel.id, false)
2585
+ },
2586
+ }, element(IconPinFillRegular, { size: 12, 'aria-hidden': true })) }) : null)
2587
+ }
2588
+
2589
+ function PanelMoreMenu({ entries, label, onOpen, onPinnedChange, renderSlot }) {
2590
+ const React = requireModule('react')
2591
+ const { Menu, Tooltip, IconAppsAddOutlineRegular } = requireModule('@deepseek-ai/dsh-client-ui-primitives')
2592
+ const [open, setOpen] = React.useState(false)
2593
+ const onDismiss = () => setOpen(false)
2594
+ return element(Menu, {
2595
+ open, onClose: onDismiss, portal: true, autoFocus: true,
2596
+ className: 'dbt-panel-more-anchor', listClassName: 'dbt-panel-more-menu',
2597
+ anchor: element(Tooltip, { label, delayMs: SIDEBAR_LAYOUT.tooltipDelayMs, side: 'bottom', portal: true,
2598
+ children: element('button', { type: 'button', className: 'dbt-panel-row',
2599
+ 'aria-label': label, 'aria-haspopup': 'menu', 'aria-expanded': open,
2600
+ onClick: () => setOpen(value => !value),
2601
+ }, element(IconAppsAddOutlineRegular, { size: SIDEBAR_LAYOUT.iconSize, 'aria-hidden': true })) }),
2602
+ }, renderSlot('sidebar.panellist.actions', { entries, onOpen, onPinnedChange, onDismiss }))
2603
+ }
2604
+
2605
+ /**
2606
+ * Guarded root: the component actually handed to `ctx.slots.register`.
2607
+ *
2608
+ * Two narrow jobs:
2609
+ * 1. Forensics — record whether a hook can legally run in this frame. That one
2610
+ * fact separates "React called us" from "something called the component as a
2611
+ * plain function", which is the difference between a props bug and an
2612
+ * unsupported call site.
2613
+ * 2. Containment — a bare call (no arguments) from anywhere outside React gets a
2614
+ * visible diagnostic block instead of throwing. A throw during render is what
2615
+ * makes the slot retire the occupant and blank the whole column.
2616
+ *
2617
+ * It deliberately owns no hook except the probe, so the short circuit below can
2618
+ * never change the real component's hook order.
2619
+ */
2620
+ function SidebarRootGuard(props) {
2621
+ const React = requireModule('react')
2622
+ const startedAt = Date.now()
2623
+ // Probe FIRST: a guard that returned before this would hide the very bare call
2624
+ // the probe exists to observe, and B would be misread as "did not crash".
2625
+ let hookOk = true
2626
+ try { React.useState(0) } catch { hookOk = false }
2627
+ const zeroArg = arguments.length === 0
2628
+ // Captured here, while this frame is still the top of the stack: inside the
2629
+ // caller, not after createElement. Empty for a React render, which needs none.
2630
+ const stack = zeroArg && !hookOk ? captureCallStack() : ''
2631
+ if (RENDER_PROBE) {
2632
+ writeRenderProbe({
2633
+ zeroArg,
2634
+ isElement: React.isValidElement(props),
2635
+ propKeys: props !== null && typeof props === 'object' ? Object.keys(props).slice(0, 8) : [],
2636
+ durationMs: Date.now() - startedAt,
2637
+ hookOk,
2638
+ stack,
2639
+ })
2640
+ if (renderProbeCount === 1) {
2641
+ writeStorage(RENDER_ENV_KEY, JSON.stringify({
2642
+ revision: BUILD_REVISION,
2643
+ reactVersion: React.version,
2644
+ propsNull: props === null || props === undefined,
2645
+ argCount: arguments.length,
2646
+ }))
2647
+ }
2648
+ }
2649
+ // Dry-run guard, after the probe.
2650
+ if (zeroArg && !hookOk) {
2651
+ return React.createElement('div', {
2652
+ 'data-better-tasks-diagnostic': 'bare-call',
2653
+ style: { padding: '10px', font: '12px/1.5 ui-monospace, monospace', color: '#ff8f8f' },
2654
+ }, `Better Tasks: sidebar root invoked outside React (revision ${BUILD_REVISION})`)
2655
+ }
2656
+ // Safe defaults keep the real component's hook sequence stable.
2657
+ const safe = props === null || props === undefined ? {} : props
2658
+ // Rendered as an element so the real component keeps its own fiber. The
2659
+ // occupant's throw came from an undefined element type (React #130) among the
2660
+ // descendant icons, which is fixed at the source; no boundary is carried here.
2661
+ return React.createElement(BetterSidebarRoot, safe)
2662
+ }
2663
+
2664
+ /** AppFrame mounts this root-scoped chrome seat only while the macOS rail is hidden. */
2665
+ function SidebarReopenControls({ toggleSidebar, startSession, useRemoteAccess, t }) {
2666
+ const { Tooltip, IconPanelLeftOutlineRegular: PanelIcon, IconNewChatOutlineRegular: NewIcon } = requireModule('@deepseek-ai/dsh-client-ui-primitives')
2667
+ return element('div', { className: 'dbt-shell-leading' },
2668
+ element(Tooltip, {
2669
+ label: t('toggle.open'), delayMs: SIDEBAR_LAYOUT.tooltipDelayMs, portal: true,
2670
+ children: element('button', {
2671
+ type: 'button', className: 'dbt-icon-button',
2672
+ 'aria-label': t('toggle.open'), 'aria-expanded': false,
2673
+ onClick: toggleSidebar,
2674
+ }, element(PanelIcon, { size: 16 })),
2675
+ }),
2676
+ (typeof useRemoteAccess !== 'function' || useRemoteAccess('session/create')) ? element(Tooltip, {
2677
+ label: t('session.new.label'), delayMs: SIDEBAR_LAYOUT.tooltipDelayMs, portal: true,
2678
+ children: element('button', {
2679
+ type: 'button', className: 'dbt-icon-button',
2680
+ 'aria-label': t('session.new.label'),
2681
+ onClick: () => startSession(),
2682
+ }, element(NewIcon, { size: 16 })),
2683
+ }) : null)
2684
+ }
2685
+
2686
+ function BetterSidebarRoot({
2687
+ collapsed,
2688
+ width,
2689
+ startSession,
2690
+ toggleSidebar,
2691
+ selectPanel,
2692
+ usePanels,
2693
+ usePanelInfo,
2694
+ useEntryPins,
2695
+ useEntryActions,
2696
+ useShortcuts,
2697
+ useRemoteAccess,
2698
+ setPanelPinned,
2699
+ nativePanelIcons = false,
2700
+ useSessions,
2701
+ useWorkspaces,
2702
+ useSessionStatus, useTaskConnection,
2703
+ useTaskPins,
2704
+ useTaskPreferences,
2705
+ setTaskPreference,
2706
+ openSession,
2707
+ moveTaskBefore,
2708
+ unpinTask,
2709
+ revealQuestion,
2710
+ canRevealQuestion,
2711
+ findTaskQuestion,
2712
+ createTaskQuestion,
2713
+ submitTaskAction,
2714
+ createTaskInput,
2715
+ t,
2716
+ renderSlot,
2717
+ renderFactorySlot,
2718
+ }) {
2719
+ const React = requireModule('react')
2720
+ const { Tooltip, IconNewChatOutlineMedium, IconNewChatOutlineRegular, ShortcutKeys } = requireModule('@deepseek-ai/dsh-client-ui-primitives')
2721
+ const panels = usePanels((value) => value)
2722
+ const unpinned = useEntryPins?.((value) => value) ?? []
2723
+ const hasEntryActions = useEntryActions?.((value) => value) ?? false
2724
+ const activePanelId = usePanelInfo?.((info) => info.activePanelId)
2725
+ const newShortcut = useShortcuts?.((rows) => rows.find(row => row.id === 'session.new'))
2726
+ const canStartSession = typeof useRemoteAccess !== 'function' || useRemoteAccess('session/create')
2727
+ const preferences = useTaskPreferences((snapshot) => snapshot.value)
2728
+ // D1: the view is a Config preference rather than plugin-local localStorage, so
2729
+ // the Host persists it and 0.2 and 0.1.5 reach it through the same path. The old
2730
+ // localStorage value is folded in once by migrateLegacySidebarView().
2731
+ const view = preferences.sidebarView === 'tasks' ? 'tasks' : 'native'
2732
+ const setView = (next) => {
2733
+ void Promise.resolve(setTaskPreference('sidebarView', next === 'tasks' ? 'tasks' : 'native')).catch(() => {})
2734
+ }
2735
+ const windowsTitlebar = typeof document !== 'undefined' && document.documentElement.hasAttribute('data-windows-titlebar')
2736
+ const [settled, setSettled] = React.useState(collapsed)
2737
+ React.useEffect(() => {
2738
+ if (!collapsed) {
2739
+ setSettled(false)
2740
+ return undefined
2741
+ }
2742
+ const timer = window.setTimeout(() => setSettled(true), 150)
2743
+ return () => window.clearTimeout(timer)
2744
+ }, [collapsed])
2745
+ const wide = windowsTitlebar ? !collapsed : !collapsed || !settled
2746
+ const twoColumns = !collapsed && (preferences.columnLayout === 'double' || (preferences.columnLayout === 'auto' && width > TASK_GRID_TWO_COLUMN_THRESHOLD))
2747
+ const lastWideWidth = React.useRef(width)
2748
+ if (!collapsed) lastWideWidth.current = width
2749
+ const toggleLabel = collapsed ? t('toggle.open') : t('toggle.collapse')
2750
+ const newLabel = t('session.new.label')
2751
+ const startNewSession = () => startSession()
2752
+
2753
+ return element('aside', {
2754
+ className: 'dbt-sidebar',
2755
+ 'data-wide': String(wide),
2756
+ 'data-collapsing': String(collapsed && wide),
2757
+ style: wide ? { width: collapsed ? lastWideWidth.current : width } : undefined,
2758
+ 'aria-label': 'DSH sidebar',
2759
+ },
2760
+ element('div', { className: 'dbt-brand-row' },
2761
+ wide ? element(canStartSession ? 'button' : 'span', {
2762
+ type: canStartSession ? 'button' : undefined,
2763
+ className: 'dbt-brand',
2764
+ 'aria-label': canStartSession ? newLabel : undefined,
2765
+ 'aria-keyshortcuts': canStartSession ? newShortcut?.aria : undefined,
2766
+ onClick: canStartSession ? startNewSession : undefined,
2767
+ },
2768
+ element('span', { className: 'dbt-brand-mark', 'aria-hidden': 'true' },
2769
+ renderSlot('sidebar.brand.mark', { size: 24 }, { fallback: element('span', null, '◆') })),
2770
+ element('span', { className: 'dbt-brand-name' },
2771
+ renderSlot('sidebar.brand.name', {}, { fallback: element('span', null, t('brand.fallback')) }))) : null,
2772
+ element(Tooltip, {
2773
+ label: toggleLabel,
2774
+ delayMs: SIDEBAR_LAYOUT.tooltipDelayMs,
2775
+ side: windowsTitlebar ? 'bottom' : 'right',
2776
+ portal: windowsTitlebar,
2777
+ children: element('button', {
2778
+ type: 'button',
2779
+ className: 'dbt-icon-button dbt-toggle',
2780
+ 'aria-label': toggleLabel,
2781
+ 'aria-expanded': !collapsed,
2782
+ onClick: toggleSidebar,
2783
+ },
2784
+ !wide && !windowsTitlebar ? element('span', { className: 'dbt-rail-mark', 'aria-hidden': 'true' },
2785
+ renderSlot('sidebar.brand.mark', { size: 24 }, { fallback: element('span', null, '◆') })) : null,
2786
+ element(IconPanelLeftOutlineRegular, { className: 'dbt-panel-icon', size: wide || windowsTitlebar ? 16 : 18 }))
2787
+ })),
2788
+ // rc.3 HOC: owner content is an option, not an owner prop or a fallback.
2789
+ // The renderer keeps this button when no optional action is registered.
2790
+ canStartSession ? element('div', { className: 'dbt-new-session-row' },
2791
+ renderSlot('sidebar.session.action', { wide }, { children: element(Tooltip, {
2792
+ label: newLabel, shortcutKeys: newShortcut?.keys, delayMs: SIDEBAR_LAYOUT.tooltipDelayMs,
2793
+ side: windowsTitlebar ? 'bottom' : 'right', disabled: wide,
2794
+ children: element('button', {
2795
+ type: 'button', className: 'dbt-new-session',
2796
+ 'aria-label': newLabel, 'aria-keyshortcuts': newShortcut?.aria, onClick: startNewSession,
2797
+ }, element('span', { className: 'dbt-new-label-mask' },
2798
+ element('span', { className: 'dbt-new-content' },
2799
+ element(wide ? IconNewChatOutlineMedium ?? IconNewChatOutlineRegular : IconNewChatOutlineRegular,
2800
+ { size: wide ? 14 : windowsTitlebar ? 16 : 18, 'aria-hidden': true }),
2801
+ wide ? element('span', { className: 'dbt-new-label' }, t('session.new')) : null)),
2802
+ wide && newShortcut?.keys?.length > 0 ? element('span', { className: 'dbt-new-shortcut', 'aria-hidden': true },
2803
+ element(ShortcutKeys, { keys: newShortcut.keys })) : null),
2804
+ }) })) : null,
2805
+ panels.length > 0 ? element('nav', { className: 'dbt-panel-list', 'aria-label': t('panels.label') },
2806
+ ...panels.filter(panel => panel.id === 'plugins' || !hasEntryActions || !unpinned.includes(panel.id)).map((panel) => element(PanelRow, {
2807
+ key: panel.id, panel, usePanelInfo, selectPanel, renderSlot, setPanelPinned,
2808
+ canUnpin: hasEntryActions && panel.id !== 'plugins', unpinLabel: t('panels.unpin', { name: panel.label }),
2809
+ renderFactorySlot: nativePanelIcons ? renderFactorySlot : undefined,
2810
+ })),
2811
+ hasEntryActions ? element(PanelMoreMenu, {
2812
+ label: t('panels.more'),
2813
+ entries: panels.filter(panel => panel.id !== 'plugins').map(panel => ({
2814
+ id: panel.id, label: panel.label, pinned: !unpinned.includes(panel.id), active: panel.id === activePanelId,
2815
+ })),
2816
+ onOpen: selectPanel, onPinnedChange: setPanelPinned, renderSlot,
2817
+ }) : null) : null,
2818
+ element('div', { className: 'dbt-workspaces' },
2819
+ element(SidebarRegion, {
2820
+ wide,
2821
+ collapsed,
2822
+ view,
2823
+ setView,
2824
+ twoColumns,
2825
+ preferences,
2826
+ toggleSidebar,
2827
+ renderSlot,
2828
+ useSessions,
2829
+ useWorkspaces,
2830
+ useSessionStatus, useTaskConnection,
2831
+ usePanelInfo,
2832
+ useTaskPins,
2833
+ openSession,
2834
+ moveTaskBefore,
2835
+ unpinTask,
2836
+ revealQuestion,
2837
+ canRevealQuestion,
2838
+ findTaskQuestion,
2839
+ createTaskQuestion,
2840
+ submitTaskAction,
2841
+ createTaskInput,
2842
+ t,
2843
+ })),
2844
+ element('div', { className: 'dbt-foot' },
2845
+ element('div', { className: 'dbt-footer-actions' }, renderSlot('sidebar.footer.action', { wide })),
2846
+ element('div', { className: 'dbt-settings' }, renderSlot('sidebar.settings', { wide }))))
2847
+ }
2848
+
2849
+ function TaskSettingsSection({ useTaskPreferences, setTaskPreference, t }) {
2850
+ const React = requireModule('react')
2851
+ const element = React.createElement
2852
+ const snapshot = useTaskPreferences((value) => value)
2853
+ const [draft, setDraft] = React.useState(snapshot.value)
2854
+ const [pending, setPending] = React.useState(false)
2855
+ const [failed, setFailed] = React.useState(false)
2856
+ React.useEffect(() => setDraft(snapshot.value), [snapshot.value])
2857
+ const writable = snapshot.status === 'ready' && snapshot.writable && !pending
2858
+ const update = (field, value) => {
2859
+ if (!writable) return
2860
+ setDraft((current) => ({ ...current, [field]: value }))
2861
+ setFailed(false)
2862
+ setPending(true)
2863
+ Promise.resolve(setTaskPreference(field, value)).catch(() => {
2864
+ setDraft(snapshot.value)
2865
+ setFailed(true)
2866
+ }).finally(() => setPending(false))
2867
+ }
2868
+ const switchRow = (field, label) => element('div', { className: 'dbt-settings-switch-row', key: field },
2869
+ element('span', null, label),
2870
+ element('button', {
2871
+ type: 'button',
2872
+ role: 'switch',
2873
+ className: 'dbt-settings-switch',
2874
+ 'aria-label': label,
2875
+ 'aria-checked': String(draft[field]),
2876
+ disabled: !writable,
2877
+ onClick: () => update(field, !draft[field]),
2878
+ }))
2879
+ const status = failed
2880
+ ? element('div', { className: 'dbt-settings-status', 'data-error': true }, t('settings.writeFailed'))
2881
+ : snapshot.status === 'loading'
2882
+ ? element('div', { className: 'dbt-settings-status' }, t('settings.loading'))
2883
+ : snapshot.status === 'unavailable'
2884
+ ? element('div', { className: 'dbt-settings-status' }, t('settings.unavailable'))
2885
+ : snapshot.status === 'ready' && !snapshot.writable
2886
+ ? element('div', { className: 'dbt-settings-status' }, t('settings.readOnly'))
2887
+ : null
2888
+ return element('section', { className: 'dbt-settings-section', 'aria-labelledby': 'dbt-settings-title' },
2889
+ element('div', { className: 'dbt-settings-heading' },
2890
+ element('h2', { id: 'dbt-settings-title' }, t('settings.title')),
2891
+ element('p', null, t('settings.description'))),
2892
+ status,
2893
+ element('div', { className: 'dbt-settings-group' },
2894
+ element('div', { className: 'dbt-settings-group-title' }, t('settings.fontSize')),
2895
+ element('div', { className: 'dbt-settings-card' },
2896
+ element('div', { className: 'dbt-settings-font-options' },
2897
+ ...[11, 12, 13, 14, 15, 16].map((size) => element('button', {
2898
+ key: size,
2899
+ type: 'button',
2900
+ className: 'dbt-settings-font-option',
2901
+ 'aria-label': `${size}px`,
2902
+ 'aria-pressed': String(draft.detailFontSize === size),
2903
+ disabled: !writable,
2904
+ onClick: () => update('detailFontSize', size),
2905
+ }, `${size}px`))),
2906
+ element('p', { className: 'dbt-settings-hint' }, t('settings.fontSizeHint')))),
2907
+ element('div', { className: 'dbt-settings-group' },
2908
+ element('div', { className: 'dbt-settings-group-title' }, t('settings.titleEmphasis')),
2909
+ element('div', { className: 'dbt-settings-card' },
2910
+ element('div', { className: 'dbt-settings-segments', 'data-options': '2' },
2911
+ ...['project', 'task'].map((emphasis) => element('button', {
2912
+ key: emphasis,
2913
+ type: 'button',
2914
+ className: 'dbt-settings-segment',
2915
+ 'aria-pressed': String(draft.titleEmphasis === emphasis),
2916
+ disabled: !writable,
2917
+ onClick: () => update('titleEmphasis', emphasis),
2918
+ }, t(`settings.titleEmphasis.${emphasis}`)))),
2919
+ element('p', { className: 'dbt-settings-hint' }, t('settings.titleEmphasisHint')))),
2920
+ element('div', { className: 'dbt-settings-group' },
2921
+ element('div', { className: 'dbt-settings-group-title' }, t('settings.defaultExpansion')),
2922
+ element('div', { className: 'dbt-settings-card' },
2923
+ switchRow('defaultTodoExpanded', t('settings.todoExpanded')),
2924
+ switchRow('defaultGoalExpanded', t('settings.goalExpanded')),
2925
+ switchRow('defaultFinalExpanded', t('settings.finalExpanded')),
2926
+ switchRow('defaultInputExpanded', t('settings.inputExpanded')),
2927
+ element('p', { className: 'dbt-settings-hint' }, t('settings.expansionHint')))),
2928
+ element('div', { className: 'dbt-settings-group' },
2929
+ element('div', { className: 'dbt-settings-group-title' }, t('settings.columns')),
2930
+ element('div', { className: 'dbt-settings-card' },
2931
+ element('div', { className: 'dbt-settings-segments' },
2932
+ ...['auto', 'single', 'double'].map((layout) => element('button', {
2933
+ key: layout,
2934
+ type: 'button',
2935
+ className: 'dbt-settings-segment',
2936
+ 'aria-pressed': String(draft.columnLayout === layout),
2937
+ disabled: !writable,
2938
+ onClick: () => update('columnLayout', layout),
2939
+ }, t(`settings.columns.${layout}`)))),
2940
+ element('p', { className: 'dbt-settings-hint' }, t('settings.columnsHint')))))
2941
+ }
2942
+
2943
+ function mountTaskSettingsSection(ctx, taskPreferences) {
2944
+ const injected = () => ({
2945
+ hooks: { taskPreferences: taskPreferences.source },
2946
+ setTaskPreference: (field, value) => taskPreferences.set(field, value),
2947
+ })
2948
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
2949
+ name: 'settings.section',
2950
+ id: 'better-tasks',
2951
+ order: 45,
2952
+ label: () => ctx.locale.bind(NS)('settings.nav'),
2953
+ locale: NS,
2954
+ inject: injected,
2955
+ }, TaskSettingsSection))
2956
+ }
2957
+
2958
+ function mountSidebar(ctx, taskPins, taskPreferences) {
2959
+ // One confirmed native membership source for the official list and task cards.
2960
+ // uiWorkspace adds the official sidebar pin-fronting policy without navigation.
2961
+ ctx.effect(() => taskPins.connectNative({
2962
+ source: ctx.get?.('workspaces')?.list,
2963
+ sessions: ctx.get?.('sessions')?.list,
2964
+ pinSession: (id) => ctx.uiWorkspace.pinSession(id),
2965
+ unpinSession: (id) => ctx.uiWorkspace.unpinSession(id),
2966
+ }), 'dsh-better-tasks: native Pin projection')
2967
+ ctx.effect(() => ctx.uiWorkspace.onSessionCreated((sessionId) => taskPins.pinCreated(sessionId)), 'dsh-better-tasks: auto-pin created sessions')
2968
+
2969
+ // 0.2's catalog is metadata-only until projections are explicitly requested.
2970
+ // Read the Host-owned snapshots for pinned sessions, including unopened ones;
2971
+ // sessions.refreshProjections deduplicates per connection and needs no retain.
2972
+ ctx.effect(() => {
2973
+ const refresh = () => {
2974
+ const sessions = ctx.get?.('sessions')
2975
+ if (typeof sessions?.refreshProjections !== 'function') return
2976
+ for (const id of taskPins.source.getSnapshot().queue.sessionIds) {
2977
+ void sessions.refreshProjections(id).catch(() => {})
2978
+ }
2979
+ }
2980
+ const stop = taskPins.source.subscribe(refresh)
2981
+ refresh()
2982
+ return stop
2983
+ }, 'dsh-better-tasks: pinned session projections')
2984
+
2985
+ const panels = createSnapshotStore([])
2986
+ const panelPins = createPanelLauncherPins()
2987
+ const nativePanelIcons = typeof ctx.slots.registerFactory === 'function'
2988
+ const entryActions = {
2989
+ getSnapshot: () => nativePanelIcons && ctx.slots.entriesOfSlot('sidebar.panellist.actions').length > 0,
2990
+ subscribe: (listener) => ctx.slots.subscribe('sidebar.panellist.actions', listener),
2991
+ }
2992
+ const emptyShortcuts = createSnapshotStore([])
2993
+ const syncPanels = () => {
2994
+ const next = ctx.slots.entriesOfSlot('sidebar.panellist').map(({ options }, index) => ({
2995
+ id: options.id,
2996
+ order: options.order ?? 0,
2997
+ registrationIndex: index,
2998
+ label: resolveLabel(options.label, options.id),
2999
+ })).filter((panel) => typeof panel.id === 'string' && panel.id !== '')
3000
+ .sort((left, right) => left.order - right.order || left.registrationIndex - right.registrationIndex)
3001
+ panels.set(next)
3002
+ }
3003
+
3004
+ ctx.effect(() => ctx.slots.subscribe('sidebar.panellist', syncPanels), 'dsh-better-tasks: panel entries')
3005
+ ctx.effect(() => ctx.locale.subscribe(syncPanels), 'dsh-better-tasks: panel labels')
3006
+
3007
+ // Optional factory lookup keeps startup independent of the input providers.
3008
+ // The surface owns its retained generation; metadata reads are not editor leases.
3009
+ const taskInputs = createTaskInputManager({
3010
+ getConversation: () => ctx.get('conversation'),
3011
+ getSessions: () => ctx.get('sessions'),
3012
+ getStatus: (id) => ctx.get('uiSession')?.sessionStatus.getSnapshot().get(id),
3013
+ getGoals: () => ctx.get('remote.goals') ?? ctx.get('remote')?.goals,
3014
+ isPinned: (id) => taskPins.source.getSnapshot().queue.sessionIds.includes(id),
3015
+ })
3016
+ const createTaskInput = (sessionId, options) => taskInputs.create(sessionId, options)
3017
+ const findTaskQuestion = (sessionId) => {
3018
+ try { return ctx.get('questionSurface')?.find?.(sessionId) }
3019
+ catch { return undefined } // Optional discovery cannot retire the sidebar root.
3020
+ }
3021
+ const createTaskQuestion = (options) => {
3022
+ const provider = ctx.get('questionSurface')
3023
+ if (typeof provider?.createCard !== 'function') throw new Error('Inline question surface is unavailable')
3024
+ return provider.createCard(options)
3025
+ }
3026
+ ctx.effect(() => () => taskInputs.dispose(), 'dsh-better-tasks: input surfaces')
3027
+
3028
+ const taskConnection = ctx.get?.('connection')?.state ?? createSnapshotStore(undefined)
3029
+ const injectProps = () => ({
3030
+ setTaskPreference: (field, value) => taskPreferences.set(field, value),
3031
+ startSession: (workspaceId, beforeOpen) => ctx.uiWorkspace.startSession(workspaceId, beforeOpen),
3032
+ toggleSidebar: () => ctx.layout.toggleSidebar(),
3033
+ selectPanel: (panelId) => {
3034
+ if (panels.getSnapshot().some(panel => panel.id === panelId)) ctx.layout.selectPanel(panelId)
3035
+ },
3036
+ setPanelPinned: (panelId, pinned) => {
3037
+ if (panels.getSnapshot().some(panel => panel.id === panelId)) panelPins.setPinned(panelId, pinned)
3038
+ },
3039
+ nativePanelIcons,
3040
+ openSession: (sessionId) => ctx.uiWorkspace.openSession(sessionId),
3041
+ moveTaskBefore: (sessionId, beforeSessionId) => taskPins.moveBefore(sessionId, beforeSessionId),
3042
+ unpinTask: (sessionId) => taskPins.setPinned(sessionId, false),
3043
+ createTaskInput,
3044
+ findTaskQuestion,
3045
+ createTaskQuestion,
3046
+ submitTaskAction: (action, text) => {
3047
+ if (action.kind !== 'rename') throw new Error('Messages must use the native input surface')
3048
+ return taskInputs.rename(action.id, text)
3049
+ },
3050
+ // Optional by design: a composition without `ui-user-questions` (or without
3051
+ // this Session's call identity) hides the action instead of rendering a dead
3052
+ // button. Probed per render, not memoized: the service may load later.
3053
+ canRevealQuestion: () => ctx.get('userQuestionPanels') !== undefined,
3054
+ revealQuestion: (sessionId, callId) => ctx.get('userQuestionPanels')?.reveal(sessionId, callId) ?? null,
3055
+ hooks: { panels, entryPins: panelPins.source, entryActions,
3056
+ shortcuts: ctx.get?.('shortcuts')?.catalog ?? ctx.shortcuts?.catalog ?? emptyShortcuts,
3057
+ taskPins: taskPins.source, taskPreferences: taskPreferences.source, taskConnection },
3058
+ })
3059
+
3060
+ ctx.slots.inject('sidebar', () => {
3061
+ // The factory is the sole owner of panellist on rc.2; keep the direct legacy
3062
+ // declaration only on renderers without factories. No duplicate slot owners.
3063
+ const stopIcon = nativePanelIcons ? ctx.slots.registerFactory({
3064
+ name: 'sidebar.panel.icon', scope: 'root',
3065
+ children: { 'sidebar.panellist': { kind: 'list', scope: 'root' } },
3066
+ }, SidebarPanelIcon) : undefined
3067
+ const stopSidebar = ctx.slots.register({
3068
+ name: 'sidebar', locale: NS,
3069
+ children: createSidebarChildDeclarations({ iconsInFactory: nativePanelIcons }),
3070
+ inject: injectProps,
3071
+ }, SidebarRootGuard)
3072
+ return () => { stopSidebar(); stopIcon?.() }
3073
+ })
3074
+ // Disabling native ui-sidebar removes more than its column root. Restore its
3075
+ // outside-the-column reopen seat without taking over the native frame.
3076
+ ctx.slots.inject('shell.leading', () => ctx.slots.register({
3077
+ name: 'shell.leading',
3078
+ locale: NS,
3079
+ inject: () => ({
3080
+ toggleSidebar: () => ctx.layout.toggleSidebar(),
3081
+ startSession: () => ctx.uiWorkspace.startSession(),
3082
+ }),
3083
+ }, SidebarReopenControls))
3084
+
3085
+ syncPanels()
3086
+ }
3087
+
3088
+ function apply(ctx) {
3089
+ ctx.effect(() => installStyles(), 'dsh-better-tasks: styles')
3090
+ ctx.effect(() => ctx.locale.register(NS, dictionaries), 'dsh-better-tasks: dictionaries')
3091
+ const taskPins = createTaskPinsClient()
3092
+ const taskPreferences = createTaskPreferencesStore()
3093
+ ctx.provide('betterTaskPins', taskPins)
3094
+ ctx.effect(() => taskPins.start(), 'dsh-better-tasks: pin projection')
3095
+ // Settings, two generations, neither name in the module-level `inject`:
3096
+ // 0.1.5 -> `settingsScope.bind({ namespace })` returns the read/write scope.
3097
+ // 0.2 -> that service is gone; `configForms.get(entryId)` returns a
3098
+ // ConfigFormController whose getSnapshot/subscribe/set are the same
3099
+ // shape. The namespace is the plugin's own entry id, which is why
3100
+ // TASK_SETTINGS_NAMESPACE is both the 0.1.5 namespace and (by
3101
+ // install convention, as every in-box plugin does) the 0.2 row id.
3102
+ // A generation that lacks one name still activates: inject is non-blocking, and
3103
+ // only the matching probe fires.
3104
+ let settingsBound = false
3105
+ /**
3106
+ * Fold the pre-D1 localStorage view preference into the Config once.
3107
+ *
3108
+ * The Config is the single source of truth from now on, so the legacy key is
3109
+ * removed only after the Host write lands; a failed or unwritable Host leaves it
3110
+ * in place for the next start. Runs behind the settings inject, which is why it
3111
+ * cannot be a render-time effect.
3112
+ * @param store - the preferences store.
3113
+ */
3114
+ let migrationPending = false
3115
+ const migrateLegacySidebarView = (store) => {
3116
+ if (migrationPending) return
3117
+ let legacy = null
3118
+ try {
3119
+ if (localStorage.getItem(VIEW_MIGRATED_KEY) !== null) return
3120
+ legacy = localStorage.getItem(LEGACY_VIEW_KEY)
3121
+ } catch {
3122
+ return
3123
+ }
3124
+ if (legacy !== 'tasks' && legacy !== 'native') {
3125
+ writeStorage(VIEW_MIGRATED_KEY, '1')
3126
+ return
3127
+ }
3128
+ if (store.source.getSnapshot().writable !== true) return
3129
+ migrationPending = true
3130
+ void Promise.resolve().then(() => store.set('sidebarView', legacy)).then(
3131
+ () => {
3132
+ try { localStorage.removeItem(LEGACY_VIEW_KEY) } catch {}
3133
+ writeStorage(VIEW_MIGRATED_KEY, '1')
3134
+ },
3135
+ () => {},
3136
+ ).finally(() => { migrationPending = false })
3137
+ }
3138
+ const bindSettings = (scope) => {
3139
+ if (settingsBound) return
3140
+ const settings = typeof scope.configForms?.get === 'function'
3141
+ ? scope.configForms.get(TASK_SETTINGS_ENTRY_ID)
3142
+ : typeof scope.settingsScope?.bind === 'function'
3143
+ ? scope.settingsScope.bind({ namespace: TASK_SETTINGS_NAMESPACE })
3144
+ : undefined
3145
+ if (settings === undefined) return
3146
+ settingsBound = true
3147
+ scope.effect(() => taskPreferences.connect(settings), 'dsh-better-tasks: settings projection')
3148
+ // Config forms load asynchronously. Run migration once a writable snapshot
3149
+ // arrives, not just while the controller still reports `loading`.
3150
+ scope.effect(() => taskPreferences.source.subscribe(() => migrateLegacySidebarView(taskPreferences)), 'dsh-better-tasks: legacy view migration')
3151
+ migrateLegacySidebarView(taskPreferences)
3152
+ mountTaskSettingsSection(scope, taskPreferences)
3153
+ }
3154
+ ctx.inject(['configForms'], bindSettings)
3155
+ ctx.inject(['settingsScope'], bindSettings)
3156
+ ctx.inject(['uiWorkspace'], (scope) => mountSidebar(scope, taskPins, taskPreferences))
3157
+ }
3158
+
3159
+ const contract = Object.freeze({
3160
+ activityRanges: ACTIVITY_RANGE_DAYS,
3161
+ childSlots: SIDEBAR_CHILD_SLOTS,
3162
+ beforeIdForTaskDrop,
3163
+ createSidebarChildDeclarations,
3164
+ derivePinnedTasks,
3165
+ deriveRecentTasks,
3166
+ indexWorkspacesBySession,
3167
+ loadProjectAppearances,
3168
+ summarizeGoal,
3169
+ summarizeSession,
3170
+ summarizeTodos,
3171
+ taskExecutionWarning,
3172
+ })
3173
+
3174
+ return { name, inject, apply, contract }
3175
+ },
3176
+ })