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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,235 @@
1
+ export const SIDEBAR_CHILD_SLOTS = Object.freeze([
2
+ 'sidebar.brand.mark',
3
+ 'sidebar.brand.name',
4
+ 'sidebar.panellist',
5
+ 'sidebar.panellist.actions',
6
+ 'sidebar.workspaces',
7
+ 'sidebar.settings',
8
+ 'sidebar.footer.action',
9
+ 'sidebar.session.action',
10
+ ])
11
+
12
+ export function createSidebarChildDeclarations({ iconsInFactory = false } = {}) {
13
+ return Object.fromEntries(SIDEBAR_CHILD_SLOTS.filter(name => !iconsInFactory || name !== 'sidebar.panellist').map((name) => [name, {
14
+ kind: name === 'sidebar.session.action' ? 'hoc'
15
+ : name === 'sidebar.panellist' || name === 'sidebar.panellist.actions' || name === 'sidebar.footer.action' ? 'list' : 'single',
16
+ scope: 'root',
17
+ }]))
18
+ }
19
+
20
+ export const ACTIVITY_RANGE_DAYS = Object.freeze({
21
+ all: null,
22
+ '1d': 1,
23
+ '3d': 3,
24
+ '7d': 7,
25
+ '14d': 14,
26
+ })
27
+
28
+ const DAY_MS = 24 * 60 * 60 * 1000
29
+
30
+ function compareStableRecency(left, right) {
31
+ if (left.updatedAt !== right.updatedAt) return right.updatedAt - left.updatedAt
32
+ const leftId = String(left.id)
33
+ const rightId = String(right.id)
34
+ return leftId < rightId ? -1 : leftId > rightId ? 1 : 0
35
+ }
36
+
37
+ export function beforeIdForTaskDrop(orderedIds, overId, half) {
38
+ if (half !== 'before' && half !== 'after') throw new Error(`unsupported drop half: ${half}`)
39
+ const index = orderedIds.indexOf(overId)
40
+ if (index === -1) throw new Error(`unknown drop target: ${overId}`)
41
+ return half === 'before' ? overId : orderedIds[index + 1] ?? null
42
+ }
43
+
44
+ export function indexWorkspacesBySession(workspaces) {
45
+ const index = new Map()
46
+ for (const workspace of workspaces) {
47
+ for (const sessionId of workspace.sessionIds) {
48
+ if (!index.has(sessionId)) index.set(sessionId, workspace)
49
+ }
50
+ }
51
+ return index
52
+ }
53
+
54
+ export async function loadProjectAppearances(fetchImpl, url = '/api/project-appearance') {
55
+ try {
56
+ const response = await fetchImpl(url, { cache: 'no-store' })
57
+ if (!response.ok) throw new Error(`appearance request failed: ${response.status}`)
58
+ const payload = await response.json()
59
+ if (!Array.isArray(payload.items) || !Array.isArray(payload.icons)) {
60
+ throw new Error('appearance response is missing items or icons')
61
+ }
62
+ return Object.freeze({
63
+ status: 'ready',
64
+ items: payload.items,
65
+ icons: payload.icons,
66
+ colorKeys: Array.isArray(payload.colorKeys) ? payload.colorKeys : [],
67
+ })
68
+ } catch (error) {
69
+ return Object.freeze({
70
+ status: 'unavailable',
71
+ items: [],
72
+ icons: [],
73
+ colorKeys: [],
74
+ error: String(error?.message ?? error),
75
+ })
76
+ }
77
+ }
78
+
79
+ export function summarizeGoal(value) {
80
+ if (value === undefined) return Object.freeze({ availability: 'unknown' })
81
+ if (value === null || value.goal.phase === 'complete') return Object.freeze({ availability: 'none' })
82
+ const reason = value.goal.blockedReason
83
+ return Object.freeze({
84
+ availability: 'ready',
85
+ id: value.goal.id,
86
+ phase: value.goal.phase,
87
+ objective: value.goal.objective,
88
+ roundsStarted: value.roundsStarted,
89
+ maxGoalRounds: value.goal.maxGoalRounds,
90
+ blockedReason: reason === undefined ? undefined : Object.freeze({ code: reason.code, message: reason.message }),
91
+ })
92
+ }
93
+
94
+ export function summarizeTodos(value) {
95
+ if (value === undefined) return Object.freeze({ availability: 'unknown' })
96
+ if (value === null) return Object.freeze({ availability: 'none' })
97
+ const counts = { pending: 0, inProgress: 0, completed: 0, total: value.length }
98
+ const items = value.map((todo) => {
99
+ if (todo.status === 'pending') counts.pending += 1
100
+ else if (todo.status === 'in_progress') counts.inProgress += 1
101
+ else if (todo.status === 'completed') counts.completed += 1
102
+ return Object.freeze({ content: todo.content, status: todo.status })
103
+ })
104
+ return Object.freeze({ availability: 'ready', ...counts, items: Object.freeze(items) })
105
+ }
106
+
107
+ /** Warning is presentation only: keep idle actions available for stopped tasks. */
108
+ export function taskExecutionWarning(summary, pendingInteraction, connectionState, catalogReady = true) {
109
+ if (connectionState === 'disconnected' || connectionState === 'connecting') return { kind: 'disconnected', detail: '' }
110
+ if (pendingInteraction != null || summary.running || !catalogReady) return null
111
+ const execution = summary.projectionValues?.betterTasksExecution
112
+ if (execution == null) return null // No capability / not yet hydrated is not failure.
113
+ if (execution.phase === 'running') return { kind: 'interrupted', detail: '' }
114
+ if (execution.phase !== 'ended') return null
115
+ const kind = execution.outcome
116
+ if (!['error', 'aborted', 'interrupted', 'max-tokens'].includes(kind)) return null
117
+ return { kind, detail: typeof execution.detail === 'string' ? execution.detail : '' }
118
+ }
119
+
120
+ export function summarizeSession(summary, pendingInteraction) {
121
+ if (pendingInteraction !== undefined) {
122
+ return Object.freeze({ status: 'block', interaction: pendingInteraction.kind })
123
+ }
124
+ if (summary.running) return Object.freeze({ status: 'running' })
125
+ return Object.freeze({ status: 'idle' })
126
+ }
127
+
128
+ /**
129
+ * Fold native live status through native subagent catalog edges, not fork ancestry.
130
+ * Status events win over older list rows, including explicit false. Walk upward
131
+ * once so grandchildren count even while their direct parent is idle. No retain,
132
+ * polling, execution ownership, or inference from a persisted open turn.
133
+ */
134
+ export function runningSessionTreeIds(list, sessionStatuses = new Map()) {
135
+ const rows = list.byId
136
+ const projections = list.projectionsBySession ?? {}
137
+ const parents = new Map()
138
+ const ids = new Set([...Object.keys(rows), ...Object.keys(projections), ...sessionStatuses.keys()])
139
+ for (const id of ids) {
140
+ const catalog = projections[id]?.values?.subagentCatalog ?? rows[id]?.projectionValues?.subagentCatalog
141
+ if (!Array.isArray(catalog)) continue
142
+ for (const child of catalog) {
143
+ ids.add(child.id)
144
+ if (!parents.has(child.id)) parents.set(child.id, new Set())
145
+ parents.get(child.id).add(id)
146
+ }
147
+ }
148
+ const running = new Set([...ids].filter((id) => (sessionStatuses.get(id)?.running ?? rows[id]?.running) === true))
149
+ const queue = [...running]
150
+ for (let index = 0; index < queue.length; index += 1) {
151
+ for (const parent of parents.get(queue[index]) ?? []) {
152
+ if (running.has(parent)) continue // Also bounds malformed cyclic catalogs.
153
+ running.add(parent)
154
+ queue.push(parent)
155
+ }
156
+ }
157
+ return running
158
+ }
159
+
160
+ function projectTask(summary, currentId, pendingInteraction) {
161
+ const projections = summary.projectionValues
162
+ return Object.freeze({
163
+ id: summary.id,
164
+ title: summary.displayTitle ?? summary.title ?? String(summary.id),
165
+ cwd: summary.cwd,
166
+ updatedAt: summary.updatedAt,
167
+ current: summary.id === currentId,
168
+ goal: summarizeGoal(projections?.goal),
169
+ todos: summarizeTodos(projections?.todos),
170
+ session: summarizeSession(summary, pendingInteraction),
171
+ })
172
+ }
173
+
174
+ /** Match the native 0.2 browser's main-view selection, not Agent running state. */
175
+ export function currentTaskSessionId(list, panelActive = false) {
176
+ if (panelActive) return undefined
177
+ const rows = Object.values(list.byId)
178
+ // 0.2's catalog removed list.current; retention is updated as the view switches.
179
+ // Never fall back to an obsolete current id once the new contract is present.
180
+ if (rows.some((row) => Object.hasOwn(row, 'retainedBy'))) {
181
+ return rows.find((row) => (row.retainedBy?.mainView ?? 0) > 0)?.id
182
+ }
183
+ return list.current // Retained 0.1.5 compatibility only.
184
+ }
185
+
186
+ /** Derive task cards in the exact Host-owned pin order. */
187
+ export function derivePinnedTasks({ list, pinnedSessionIds, pendingInteractions = new Map(), panelActive = false, sessionStatuses = new Map() }) {
188
+ if (!Array.isArray(pinnedSessionIds)) throw new Error('pinnedSessionIds must be an array')
189
+ const seen = new Set()
190
+ const tasks = []
191
+ const currentId = currentTaskSessionId(list, panelActive)
192
+ const runningIds = runningSessionTreeIds(list, sessionStatuses)
193
+ for (const id of pinnedSessionIds) {
194
+ if (seen.has(id)) continue
195
+ seen.add(id)
196
+ const summary = list.byId[id]
197
+ if (summary === undefined || summary.blank || summary.origin === 'subagent') continue
198
+ tasks.push(projectTask({ ...summary, running: runningIds.has(id) }, currentId, pendingInteractions.get(id)))
199
+ }
200
+ return tasks
201
+ }
202
+
203
+ /**
204
+ * Legacy bounded recent projection retained for compatibility tests and rollback.
205
+ * The task UI no longer uses it as membership or ordering authority.
206
+ */
207
+ export function deriveRecentTasks({
208
+ list,
209
+ archivedSessionIds = [],
210
+ pendingInteractions = new Map(),
211
+ activityRange = 'all',
212
+ now = Date.now(),
213
+ limit = 10,
214
+ }) {
215
+ if (!Object.hasOwn(ACTIVITY_RANGE_DAYS, activityRange)) {
216
+ throw new Error(`unsupported activity range: ${activityRange}`)
217
+ }
218
+ if (!Number.isSafeInteger(limit) || limit < 0) throw new Error('limit must be a non-negative safe integer')
219
+
220
+ const archived = new Set(archivedSessionIds)
221
+ const days = ACTIVITY_RANGE_DAYS[activityRange]
222
+ const cutoff = days === null ? Number.NEGATIVE_INFINITY : now - days * DAY_MS
223
+ const visible = []
224
+
225
+ for (const id of list.ids) {
226
+ const summary = list.byId[id]
227
+ if (summary === undefined) continue
228
+ if (summary.blank || summary.origin === 'subagent' || archived.has(summary.id)) continue
229
+ if (summary.updatedAt < cutoff) continue
230
+ visible.push(summary)
231
+ }
232
+
233
+ visible.sort(compareStableRecency)
234
+ return visible.slice(0, limit).map((summary) => projectTask(summary, list.current, pendingInteractions.get(summary.id)))
235
+ }
@@ -0,0 +1,139 @@
1
+ export const TASK_SETTINGS_NAMESPACE = 'better-tasks-ui'
2
+ // 0.2 dsh-settings.describe emits ns=entry.options.id and write resolves that
3
+ // same options.id (lib/index.js:443,502 in 0.2.0-rc.2). Inspect's qualified
4
+ // loader id (include:better-tasks-ui) is NOT the settings namespace.
5
+ export const TASK_SETTINGS_ENTRY_ID = TASK_SETTINGS_NAMESPACE
6
+ export const TASK_DETAIL_FONT_MIN = 11
7
+ export const TASK_DETAIL_FONT_MAX = 16
8
+ export const TASK_COLUMN_LAYOUTS = Object.freeze(['auto', 'single', 'double'])
9
+ export const TASK_TITLE_EMPHASES = Object.freeze(['project', 'task'])
10
+ /** Sidebar body the browser shows: the native session list or the task swimlane. */
11
+ export const TASK_SIDEBAR_VIEWS = Object.freeze(['native', 'tasks'])
12
+ export const DEFAULT_TASK_PREFERENCES = Object.freeze({
13
+ detailFontSize: 13,
14
+ defaultTodoExpanded: false,
15
+ defaultGoalExpanded: false,
16
+ defaultFinalExpanded: true,
17
+ defaultInputExpanded: false,
18
+ columnLayout: 'auto',
19
+ titleEmphasis: 'task',
20
+ sidebarView: 'native',
21
+ })
22
+
23
+ const FIELDS = new Set(Object.keys(DEFAULT_TASK_PREFERENCES))
24
+
25
+ export function decodeTaskPreferences(value) {
26
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
27
+ const detailFontSize = value.detailFontSize
28
+ const columnLayout = value.columnLayout
29
+ const titleEmphasis = value.titleEmphasis === undefined ? DEFAULT_TASK_PREFERENCES.titleEmphasis : value.titleEmphasis
30
+ if (!TASK_TITLE_EMPHASES.includes(titleEmphasis)) return undefined
31
+ // Absent reads as the default, like titleEmphasis: rows written before this
32
+ // preference existed must keep decoding.
33
+ const sidebarView = value.sidebarView === undefined ? DEFAULT_TASK_PREFERENCES.sidebarView : value.sidebarView
34
+ if (!TASK_SIDEBAR_VIEWS.includes(sidebarView)) return undefined
35
+ if (!Number.isInteger(detailFontSize) || detailFontSize < TASK_DETAIL_FONT_MIN || detailFontSize > TASK_DETAIL_FONT_MAX) return undefined
36
+ if (!TASK_COLUMN_LAYOUTS.includes(columnLayout)) return undefined
37
+ for (const field of ['defaultTodoExpanded', 'defaultGoalExpanded', 'defaultFinalExpanded', 'defaultInputExpanded']) {
38
+ if (typeof value[field] !== 'boolean') return undefined
39
+ }
40
+ return Object.freeze({
41
+ detailFontSize,
42
+ defaultTodoExpanded: value.defaultTodoExpanded,
43
+ defaultGoalExpanded: value.defaultGoalExpanded,
44
+ defaultFinalExpanded: value.defaultFinalExpanded,
45
+ defaultInputExpanded: value.defaultInputExpanded,
46
+ columnLayout,
47
+ titleEmphasis,
48
+ sidebarView,
49
+ })
50
+ }
51
+
52
+ function fallbackSnapshot() {
53
+ return Object.freeze({
54
+ status: 'unavailable',
55
+ value: DEFAULT_TASK_PREFERENCES,
56
+ revision: undefined,
57
+ writable: false,
58
+ mode: 'memory',
59
+ })
60
+ }
61
+
62
+ export function createTaskPreferencesStore() {
63
+ let snapshot = fallbackSnapshot()
64
+ let scope
65
+ let unsubscribe
66
+ const listeners = new Set()
67
+
68
+ const publish = (next) => {
69
+ if (snapshot.status === next.status && snapshot.value === next.value && snapshot.revision === next.revision && snapshot.writable === next.writable && snapshot.mode === next.mode) return
70
+ snapshot = Object.freeze(next)
71
+ for (const listener of listeners) listener()
72
+ }
73
+
74
+ const sync = () => {
75
+ if (scope === undefined) return publish(fallbackSnapshot())
76
+ const source = scope.getSnapshot()
77
+ const decoded = decodeTaskPreferences(source.value)
78
+ publish({
79
+ status: source.status,
80
+ value: decoded ?? DEFAULT_TASK_PREFERENCES,
81
+ revision: source.revision,
82
+ // ConfigFormController may report the document's writable=true even when
83
+ // this entry is absent. Never write defaults into an unserved namespace.
84
+ writable: source.status === 'ready' && decoded !== undefined && source.writable === true,
85
+ mode: source.mode,
86
+ })
87
+ }
88
+
89
+ const disconnect = () => {
90
+ unsubscribe?.()
91
+ unsubscribe = undefined
92
+ scope = undefined
93
+ sync()
94
+ }
95
+
96
+ return Object.freeze({
97
+ source: Object.freeze({
98
+ getSnapshot: () => snapshot,
99
+ subscribe(listener) {
100
+ listeners.add(listener)
101
+ return () => listeners.delete(listener)
102
+ },
103
+ }),
104
+ connect(nextScope) {
105
+ disconnect()
106
+ scope = nextScope
107
+ sync()
108
+ unsubscribe = nextScope.subscribe(sync)
109
+ return () => {
110
+ if (scope !== nextScope) return
111
+ disconnect()
112
+ }
113
+ },
114
+ set(field, value) {
115
+ if (!FIELDS.has(field)) return Promise.reject(new TypeError(`unknown Better Tasks setting: ${String(field)}`))
116
+ if (scope === undefined || !snapshot.writable) return Promise.reject(new Error('Better Tasks settings are not writable'))
117
+ const optimistic = decodeTaskPreferences({ ...snapshot.value, [field]: value })
118
+ if (optimistic === undefined) return Promise.reject(new TypeError(`invalid Better Tasks setting: ${field}`))
119
+ const writeScope = scope
120
+ publish({ ...snapshot, value: optimistic })
121
+ const rejected = (error) => {
122
+ if (scope === writeScope) sync()
123
+ throw error
124
+ }
125
+ let result
126
+ try {
127
+ result = writeScope.set(field, value)
128
+ } catch (error) {
129
+ return Promise.reject(error).catch(rejected)
130
+ }
131
+ return Promise.resolve(result).then((accepted) => {
132
+ // 0.2 resolves false after a rejected mutation and recovery read; it does
133
+ // not reject. Legacy settings scopes may resolve void on success.
134
+ if (accepted === false) throw new Error('Better Tasks settings write was rejected by Host')
135
+ return accepted
136
+ }).catch(rejected)
137
+ },
138
+ })
139
+ }