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

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