@ikenga/pkg-tasks 0.2.0 → 0.4.1

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.
@@ -1,17 +1,73 @@
1
1
  // Tasks main view — ported from views/TasksView.tsx. List + filter bar +
2
- // grouped rows + master/detail split + in-body view switcher (Tasks/Agenda/
3
- // Triage) with localStorage persistence.
2
+ // grouped rows + master/detail split. The view switcher (Tasks/Agenda/Triage/
3
+ // Sweeper/Done) and the list filters BOTH live in the shell side-menu now (see
4
+ // buildTasksMenu + the publish effect); there's no in-pane tab bar. View
5
+ // choice persists to localStorage.
4
6
 
5
7
  import { html, cn, Icon, Button, useState, useMemo, useEffect, useQuery } from '../../lib/ui.js';
6
- import { hostDbQuery, hostSendToActiveSession, isStandalone } from '../../lib/bridge.js';
8
+ import { hostDbQuery, hostSendToActiveSession, isStandalone, setMenu } from '../../lib/bridge.js';
7
9
  import { queryKeys } from '../../lib/query-keys.js';
8
10
  import { TASKS_LIST_COLUMNS, triageCountsQuery } from '../../lib/queries.js';
11
+ import { CURRENT_USER } from '../../lib/assignees.js';
9
12
  import { groupTasks } from '../../lib/shared.js';
10
13
  import { TaskRow } from './task-row.js';
14
+ import { CreateTaskForm } from './create-task-form.js';
11
15
  import { TaskDetailPane } from './task-detail-pane.js';
12
- import { ViewTabs } from './view-tabs.js';
13
16
  import { AgendaView } from './agenda-view.js';
14
17
  import { TriageView } from './triage-view.js';
18
+ import { SweeperView } from './sweeper-view.js';
19
+ import { DoneView } from './done-view.js';
20
+
21
+ // Shell side-menu model. Per the user's call (2026-05-28), the five VIEW modes
22
+ // live in the sidebar alongside the list FILTER facets — one nav surface, like
23
+ // Ngwa. The filters are List-only, so they render dimmed (disabled) on any
24
+ // non-list view (mirrors Ngwa's "Kind dims on Analyze"). `buildTasksMenu`
25
+ // computes the flat item list with per-item `active` + `disabled` flags; the
26
+ // publish effect re-sends it whenever view / active-filter / triage-badge
27
+ // changes. See pkg-mode.tsx for how the shell renders sections + dim + active.
28
+ const VIEW_ITEMS = [
29
+ { id: 'v:tasks', label: 'Tasks', icon: 'check-square' },
30
+ { id: 'v:agenda', label: 'Agenda', icon: 'calendar-days' },
31
+ { id: 'v:triage', label: 'Triage', icon: 'stethoscope' },
32
+ { id: 'v:sweeper', label: 'Sweeper', icon: 'broom' },
33
+ { id: 'v:done', label: 'Done', icon: 'check-check' },
34
+ ];
35
+ const FILTER_ITEMS = [
36
+ // Filter section (the implicit-first group in the design's TASKS_SIDEBAR).
37
+ { id: 'f:all', label: 'All tasks', icon: 'list-checks', section: 'Filter' },
38
+ { id: 'f:today', label: 'Today', icon: 'sun', section: 'Filter' },
39
+ { id: 'f:overdue', label: 'Overdue', icon: 'alert-triangle', section: 'Filter' },
40
+ { id: 'f:thisweek', label: 'This week', icon: 'calendar-days', section: 'Filter' },
41
+ { id: 'f:autoclosed', label: 'Auto-closed', icon: 'check-check', section: 'Filter' },
42
+ { id: 'd:finance', label: 'Finance', icon: 'trending-up', section: 'By domain' },
43
+ { id: 'd:mail', label: 'Mail', icon: 'mail', section: 'By domain' },
44
+ { id: 'd:content', label: 'Content', icon: 'pencil', section: 'By domain' },
45
+ { id: 'd:outbound', label: 'Outbound', icon: 'send', section: 'By domain' },
46
+ { id: 'o:me', label: 'Me', icon: 'list-checks', section: 'By owner' },
47
+ { id: 'o:agents', label: 'Agents', icon: 'activity', section: 'By owner' },
48
+ ];
49
+
50
+ /**
51
+ * @param {TaskView} view current mounted view
52
+ * @param {string | null} activeFilter last-applied filter id (e.g. 'f:today')
53
+ * @param {number | null} triageBadge needs-attention count for the Triage row
54
+ */
55
+ function buildTasksMenu(view, activeFilter, triageBadge) {
56
+ const filtersInert = view !== 'tasks';
57
+ const viewRows = VIEW_ITEMS.map((it) => ({
58
+ ...it,
59
+ section: 'View',
60
+ active: `v:${view}` === it.id,
61
+ badge: it.id === 'v:triage' && triageBadge ? triageBadge : undefined,
62
+ }));
63
+ const filterRows = FILTER_ITEMS.map((it) => ({
64
+ ...it,
65
+ disabled: filtersInert,
66
+ // Highlight the applied filter only while the list is the active view.
67
+ active: !filtersInert && activeFilter === it.id,
68
+ }));
69
+ return [...viewRows, ...filterRows];
70
+ }
15
71
 
16
72
  /** @typedef {import('../../lib/queries.js').Task} Task */
17
73
  /** @typedef {import('../../lib/queries.js').TaskStatus} TaskStatus */
@@ -20,11 +76,34 @@ import { TriageView } from './triage-view.js';
20
76
 
21
77
  const VIEW_STORAGE_KEY = 'ikenga-tasks-view';
22
78
 
79
+ // Owner-filter identities. The sidebar "By owner" facet and the in-pane Owner
80
+ // dropdown MUST agree on these values, or selecting one won't reflect in the
81
+ // other (and "Me" filtered to a different person than the sidebar did).
82
+ // CURRENT_USER is imported from lib/assignees.js (the one place that literal
83
+ // lives, shared with the create form + reassign picker).
84
+ // Sentinel for "any agent" — the query maps it to assignee_type='agent' rather
85
+ // than a literal assigned_to (agents aren't a single owner id).
86
+ const OWNER_AGENTS = '__agents__';
87
+
88
+ // Display names for the slim header — the bar reflects the active view (the
89
+ // sidebar already says "Tasks", so the in-pane bar holds context + action, not
90
+ // the domain name). See the header block in the render.
91
+ /** @type {Record<TaskView, string>} */
92
+ const VIEW_LABELS = {
93
+ tasks: 'Tasks',
94
+ agenda: 'Agenda',
95
+ triage: 'Triage',
96
+ sweeper: 'Sweeper',
97
+ done: 'Done',
98
+ };
99
+
23
100
  /** @returns {TaskView} */
24
101
  function loadView() {
25
102
  try {
26
103
  const v = localStorage.getItem(VIEW_STORAGE_KEY);
27
- if (v === 'tasks' || v === 'agenda' || v === 'triage') return v;
104
+ if (v === 'tasks' || v === 'agenda' || v === 'triage' || v === 'sweeper' || v === 'done') {
105
+ return v;
106
+ }
28
107
  } catch {
29
108
  /* localStorage unavailable (sandboxed iframe) — fall through */
30
109
  }
@@ -53,6 +132,15 @@ export function TasksView({ activeFeature } = {}) {
53
132
  /** @type {[Set<GroupKey>, (f: (prev: Set<GroupKey>) => Set<GroupKey>) => void]} */
54
133
  const [collapsed, setCollapsed] = useState(/** @type {Set<GroupKey>} */ (new Set(['later'])));
55
134
  const [view, setView] = useState(loadView);
135
+ // Last-applied sidebar filter id (e.g. 'f:today', 'd:finance', 'o:me') — kept
136
+ // so the sidebar can re-highlight it when the List view is active. Defaults
137
+ // to 'f:all' (the unfiltered list).
138
+ const [activeFilter, setActiveFilter] = useState('f:all');
139
+ // Time-bucket narrowing for the Today/Overdue/This week/Auto-closed facets.
140
+ // null = show every group. When set to a GroupKey, the list renders only
141
+ // that group — so those sidebar items actually FILTER, not just scroll.
142
+ /** @type {[GroupKey | null, (v: GroupKey | null) => void]} */
143
+ const [timeBucket, setTimeBucket] = useState(/** @type {GroupKey | null} */ (null));
56
144
 
57
145
  /** @param {TaskView} v */
58
146
  function changeView(v) {
@@ -65,28 +153,81 @@ export function TasksView({ activeFeature } = {}) {
65
153
  }
66
154
 
67
155
  // Shell side-menu selection (host.pkg.setMenu → royaltiSuite.activeFeature).
68
- // View ids switch the mounted view; `f:<group>` filter ids jump the Tasks
69
- // list to that group (expanding it, surfacing auto-closed when relevant).
156
+ // The sidebar carries BOTH the view switcher and the list filters (one nav
157
+ // surface, Ngwa-style). id taxonomy:
158
+ //
159
+ // v:<view> — switch the mounted view (tasks|agenda|triage|sweeper|done)
160
+ // f:all — list: clear all filters, show open
161
+ // f:today — list: expand "today" group + scroll to it
162
+ // f:overdue — list: expand "overdue" group + scroll
163
+ // f:thisweek — list: expand "week" group + scroll
164
+ // f:autoclosed — list: toggle Show auto-closed on + expand "autoclosed"
165
+ // d:<category> — list: filter by category column (Finance/Mail/…)
166
+ // o:me|o:agents — list: filter by owner (me = hello@royalti.io)
167
+ //
168
+ // Filter ids only fire while the list is (or becomes) the active view; the
169
+ // shell already dims them on other views, but we also force view→tasks here
170
+ // so a stray dispatch can't apply a filter the user can't see.
70
171
  useEffect(() => {
71
172
  if (!activeFeature) return;
72
- if (activeFeature === 'tasks' || activeFeature === 'agenda' || activeFeature === 'triage') {
73
- setView(activeFeature);
173
+
174
+ // View switch.
175
+ if (activeFeature.startsWith('v:')) {
176
+ const v = activeFeature.slice(2);
177
+ if (v === 'tasks' || v === 'agenda' || v === 'triage' || v === 'sweeper' || v === 'done') {
178
+ changeView(/** @type {TaskView} */ (v));
179
+ }
74
180
  return;
75
181
  }
182
+
183
+ // Time-bucket facets. These NARROW the list to one group (real filter), not
184
+ // just scroll to it. 'all' resets every filter incl. the bucket.
76
185
  if (activeFeature.startsWith('f:')) {
77
- const key = /** @type {GroupKey} */ (activeFeature.slice(2)); // today|overdue|autoclosed
186
+ const sub = activeFeature.slice(2);
78
187
  setView('tasks');
188
+ setActiveFilter(activeFeature);
189
+ if (sub === 'all') {
190
+ setTimeBucket(null);
191
+ setStatusFilter('');
192
+ setOwnerFilter('');
193
+ setCategoryFilter('');
194
+ setSearch('');
195
+ return;
196
+ }
197
+ /** @type {Record<string, GroupKey>} */
198
+ const groupKey = { today: 'today', overdue: 'overdue', thisweek: 'week', autoclosed: 'autoclosed' };
199
+ const key = groupKey[sub];
200
+ if (!key) return;
79
201
  if (key === 'autoclosed') setShowAutoClosed(true);
202
+ // Make sure the bucket isn't also collapsed (so its rows show once it's
203
+ // the only group on screen).
80
204
  setCollapsed((prev) => {
81
205
  const next = new Set(prev);
82
206
  next.delete(key);
83
207
  return next;
84
208
  });
85
- // Defer so the Tasks view + group are mounted before we scroll to it.
86
- requestAnimationFrame(() => {
87
- const el = document.querySelector(`.tk-list [data-group="${key}"]`);
88
- if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
89
- });
209
+ setTimeBucket(key);
210
+ return;
211
+ }
212
+
213
+ // Category filter (`By domain` section). Maps the design's four facet
214
+ // labels to whatever the row's `category` column actually contains.
215
+ if (activeFeature.startsWith('d:')) {
216
+ setView('tasks');
217
+ setActiveFilter(activeFeature);
218
+ setCategoryFilter(activeFeature.slice(2));
219
+ return;
220
+ }
221
+
222
+ // Owner filter (`By owner` section). `me` maps to the logged-in email;
223
+ // `agents` is a sentinel the query layer doesn't yet honour — we fall
224
+ // back to clearing the human filter so the agent rows show through.
225
+ if (activeFeature.startsWith('o:')) {
226
+ setView('tasks');
227
+ setActiveFilter(activeFeature);
228
+ const who = activeFeature.slice(2);
229
+ setOwnerFilter(who === 'me' ? CURRENT_USER : who === 'agents' ? OWNER_AGENTS : '');
230
+ return;
90
231
  }
91
232
  }, [activeFeature]);
92
233
 
@@ -95,6 +236,54 @@ export function TasksView({ activeFeature } = {}) {
95
236
  const { data: triageCounts } = useQuery(triageCountsQuery());
96
237
  const triageBadge = triageCounts ? triageCounts.needsAttention : null;
97
238
 
239
+ // Distinct categories straight from the table (NOT from the filtered list, or
240
+ // the option set would collapse to the active filter). Drives the Category
241
+ // dropdown so it lists real values + always reflects the sidebar's domain pick.
242
+ const { data: categoryRows } = useQuery({
243
+ queryKey: queryKeys.tasks.list('distinct-categories'),
244
+ queryFn: async () => {
245
+ const rows = await hostDbQuery(
246
+ "SELECT DISTINCT category FROM tasks WHERE category IS NOT NULL AND category <> '' ORDER BY category",
247
+ [],
248
+ );
249
+ return /** @type {{ category: string }[]} */ (rows);
250
+ },
251
+ });
252
+ const categoryOptions = useMemo(() => {
253
+ const set = new Set((categoryRows ?? []).map((r) => r.category));
254
+ if (categoryFilter) set.add(categoryFilter); // ensure the active pick is selectable
255
+ return Array.from(set).sort();
256
+ }, [categoryRows, categoryFilter]);
257
+
258
+ // Imperatively reflect filter state onto the native <select>s. Preact's
259
+ // controlled `value` doesn't re-apply on external (sidebar-driven) changes in
260
+ // this htm build, so we set each select's .value after render. Runs after the
261
+ // filterbar exists (view==='tasks') and whenever a filter or the option set
262
+ // changes.
263
+ useEffect(() => {
264
+ if (view !== 'tasks') return;
265
+ const fb = document.querySelector('.tk-filterbar');
266
+ if (!fb) return;
267
+ const set = (name, val) => {
268
+ const el = fb.querySelector(`select[data-filter="${name}"]`);
269
+ if (el && el.value !== val) el.value = val;
270
+ };
271
+ set('status', statusFilter);
272
+ set('owner', ownerFilter);
273
+ set('category', categoryFilter);
274
+ }, [view, statusFilter, ownerFilter, categoryFilter, categoryOptions]);
275
+
276
+ // Publish (and keep refreshing) the shell side-menu. Re-sends whenever the
277
+ // view, the active filter, or the triage badge changes so the sidebar's
278
+ // active-highlight, filter-dim, and Triage count stay in lockstep with the
279
+ // pane. Skipped in standalone preview (no host to publish to).
280
+ useEffect(() => {
281
+ if (isStandalone()) return;
282
+ setMenu(buildTasksMenu(view, activeFilter, triageBadge)).catch((e) =>
283
+ console.warn('[tasks] setMenu failed', e),
284
+ );
285
+ }, [view, activeFilter, triageBadge]);
286
+
98
287
  const { data, isLoading, error } = useQuery({
99
288
  queryKey: queryKeys.tasks.list(
100
289
  `${statusFilter || 'open'}|${ownerFilter}|${categoryFilter}|${showAutoClosed ? 'ac' : 'no-ac'}`,
@@ -119,7 +308,9 @@ export function TasksView({ activeFeature } = {}) {
119
308
  } else {
120
309
  where.push("status IN ('pending','in_progress','blocked')");
121
310
  }
122
- if (ownerFilter) {
311
+ if (ownerFilter === OWNER_AGENTS) {
312
+ where.push("assignee_type = 'agent'");
313
+ } else if (ownerFilter) {
123
314
  where.push('assigned_to = ?');
124
315
  params.push(ownerFilter);
125
316
  }
@@ -147,6 +338,13 @@ export function TasksView({ activeFeature } = {}) {
147
338
  [data, showAutoClosed],
148
339
  );
149
340
 
341
+ // When a time facet is active, show only that group (Today/Overdue/This week/
342
+ // Auto-closed actually filter). null → every group.
343
+ const visibleGroups = useMemo(
344
+ () => (timeBucket ? groups.filter((g) => g.key === timeBucket) : groups),
345
+ [groups, timeBucket],
346
+ );
347
+
150
348
  const openCount = useMemo(
151
349
  () => data?.filter((t) => t.status !== 'completed' && t.status !== 'cancelled').length ?? 0,
152
350
  [data],
@@ -164,16 +362,20 @@ export function TasksView({ activeFeature } = {}) {
164
362
 
165
363
  const filterActive = !!statusFilter || !!ownerFilter || !!categoryFilter || !!search.trim();
166
364
 
167
- const [creating, setCreating] = useState(false);
365
+ // Inline create-form visibility. The primary "New task" button toggles this;
366
+ // the form INSERTs directly via host.dbExec (createTask write helper).
367
+ const [showCreate, setShowCreate] = useState(false);
168
368
 
169
- // Create = dispatch, NOT anon insert. Anon RLS on `tasks` only grants UPDATE
170
- // of status/completed_at — never INSERT — so a new task cannot be written
171
- // client-side. Instead we seed a user turn into the shell's active Claude
172
- // session (host.sendToActiveSession); the agent creates the task via its
173
- // privileged path. Disabled in standalone (no host to dispatch to).
174
- async function createTask() {
175
- if (creating || isStandalone()) return;
176
- setCreating(true);
369
+ const [dispatching, setDispatching] = useState(false);
370
+
371
+ // Secondary path: "send to your Chi" — seed a user turn into the shell's
372
+ // active Claude session so the agent creates the task conversationally. This
373
+ // used to be the ONLY create path (anon RLS blocked client-side INSERT); now
374
+ // that host.dbExec permits a real INSERT it's kept as the natural-language
375
+ // alternative alongside the direct form. Disabled in standalone (no host).
376
+ async function dispatchToChi() {
377
+ if (dispatching || isStandalone()) return;
378
+ setDispatching(true);
177
379
  try {
178
380
  await hostSendToActiveSession(
179
381
  'Create a new task. Ask me for the title, owner, priority, and due date, then add it to the tasks table.',
@@ -181,7 +383,7 @@ export function TasksView({ activeFeature } = {}) {
181
383
  } catch (e) {
182
384
  console.warn('[tasks] create dispatch failed', e);
183
385
  } finally {
184
- setCreating(false);
386
+ setDispatching(false);
185
387
  }
186
388
  }
187
389
 
@@ -197,39 +399,45 @@ export function TasksView({ activeFeature } = {}) {
197
399
 
198
400
  return html`
199
401
  <div class="tk-screen">
200
- <div class="tk-frame" style=${{ flex: 1 }}>
201
- <div class="tk-frame-head">
202
- <div class="tk-frame-title-wrap">
203
- <${Icon} name="check-square" size=${18} className="tk-frame-title-mark" />
204
- <div>
205
- <h2 class="tk-frame-title">
206
- Tasks
207
- <span class="tk-frame-count">(${openCount} open · ${autoClosedCount} auto-closed)</span>
208
- </h2>
209
- <div class="tk-frame-sub">
210
- Cross-cutting work — humans + agents. Click a row to inspect.
211
- </div>
212
- </div>
402
+ <div class="frame" style=${{ flex: 1 }}>
403
+ <div class="frame-head">
404
+ <div class="frame-title-wrap">
405
+ <${Icon} name="check-square" size=${15} className="frame-title-mark" />
406
+ <h2 class="frame-title">
407
+ ${VIEW_LABELS[view] ?? 'Tasks'}
408
+ ${autoClosedCount > 0 &&
409
+ html`<span class="tk-frame-count" ${autoClosedCount} auto-closed</span>`}
410
+ </h2>
213
411
  </div>
214
412
  <div style=${{ display: 'flex', gap: 6, alignItems: 'center' }}>
215
413
  <${Button}
414
+ variant="outline"
216
415
  size="sm"
217
416
  type="button"
218
- disabled=${creating || isStandalone()}
219
- title=${isStandalone() ? 'Dispatch unavailable in standalone preview' : 'Dispatch a task to your Chi'}
220
- onClick=${createTask}
417
+ disabled=${dispatching || isStandalone()}
418
+ title=${isStandalone() ? 'Dispatch unavailable in standalone preview' : 'Hand the task off to your Chi to create conversationally'}
419
+ onClick=${dispatchToChi}
221
420
  >
222
- <${Icon} name=${creating ? 'loader' : 'plus'} size=${12} className=${creating ? 'tk-spin' : undefined} />
223
- ${creating ? 'Dispatching…' : 'New task'}
421
+ <${Icon} name=${dispatching ? 'loader' : 'terminal'} size=${12} className=${dispatching ? 'tk-spin' : undefined} />
422
+ ${dispatching ? 'Dispatching…' : 'Send to your Chi'}
423
+ </${Button}>
424
+ <${Button}
425
+ size="sm"
426
+ type="button"
427
+ onClick=${() => setShowCreate((v) => !v)}
428
+ >
429
+ <${Icon} name=${showCreate ? 'check-square' : 'plus'} size=${12} />
430
+ New task
224
431
  </${Button}>
225
432
  </div>
226
433
  </div>
227
434
 
228
- ${isStandalone() &&
229
- html`<${ViewTabs} view=${view} onChange=${changeView} triageCount=${triageBadge} />`}
435
+ ${showCreate && html`<${CreateTaskForm} onClose=${() => setShowCreate(false)} />`}
230
436
 
231
437
  ${view === 'agenda' && html`<${AgendaView} tasks=${data ?? []} filterActive=${filterActive} />`}
232
438
  ${view === 'triage' && html`<${TriageView} listTasks=${data ?? []} />`}
439
+ ${view === 'sweeper' && html`<${SweeperView} />`}
440
+ ${view === 'done' && html`<${DoneView} />`}
233
441
 
234
442
  ${view === 'tasks' && html`
235
443
  <div class="tk-filterbar">
@@ -243,33 +451,26 @@ export function TasksView({ activeFeature } = {}) {
243
451
  />
244
452
  </div>
245
453
  <span class="label">Status</span>
454
+ ${/* Preact's controlled <select value> doesn't re-apply its value on
455
+ an externally-driven state change in this htm build (the option
456
+ display lags the real filter), so the actual selection is synced
457
+ imperatively by data-filter in the effect below. */ ''}
246
458
  <select
247
- value=${statusFilter}
459
+ data-filter="status"
248
460
  onChange=${(e) => setStatusFilter(/** @type {'' | TaskStatus} */ (e.target.value))}
249
461
  >
250
462
  ${STATUS_OPTIONS.map((o) => html`<option key=${o.value} value=${o.value}>${o.label}</option>`)}
251
463
  </select>
252
464
  <span class="label">Owner</span>
253
- <select value=${ownerFilter} onChange=${(e) => setOwnerFilter(e.target.value)}>
465
+ <select data-filter="owner" onChange=${(e) => setOwnerFilter(e.target.value)}>
254
466
  <option value="">Anyone</option>
255
- <option value="nedjamez">Me</option>
256
- <option value="cfo-agent">cfo-agent</option>
257
- <option value="cmo-agent">cmo-agent</option>
258
- <option value="cto-agent">cto-agent</option>
259
- <option value="cpo-agent">cpo-agent</option>
260
- <option value="vp-sales-agent">vp-sales-agent</option>
261
- <option value="blog-writer">blog-writer</option>
467
+ <option value=${CURRENT_USER}>Me</option>
468
+ <option value=${OWNER_AGENTS}>Agents</option>
262
469
  </select>
263
470
  <span class="label">Category</span>
264
- <select value=${categoryFilter} onChange=${(e) => setCategoryFilter(e.target.value)}>
471
+ <select data-filter="category" onChange=${(e) => setCategoryFilter(e.target.value)}>
265
472
  <option value="">All</option>
266
- <option value="sales">sales</option>
267
- <option value="finance">finance</option>
268
- <option value="marketing">marketing</option>
269
- <option value="technical">technical</option>
270
- <option value="product">product</option>
271
- <option value="communication">communication</option>
272
- <option value="operations">operations</option>
473
+ ${categoryOptions.map((c) => html`<option key=${c} value=${c}>${c}</option>`)}
273
474
  </select>
274
475
  <button
275
476
  type="button"
@@ -300,49 +501,58 @@ export function TasksView({ activeFeature } = {}) {
300
501
  </div>
301
502
  </div>
302
503
  `}
303
- ${data && data.length === 0 && !isLoading && html`
504
+ ${!isLoading && !error && visibleGroups.length === 0 && html`
304
505
  <div class="tk-empty-box">No tasks match.</div>
305
506
  `}
306
- ${groups.map((g) => {
507
+ ${visibleGroups.flatMap((g) => {
307
508
  const isCollapsed = collapsed.has(g.key);
308
- return html`
309
- <div key=${g.key}>
310
- <div
311
- role="button"
312
- tabIndex=${0}
313
- aria-expanded=${!isCollapsed}
314
- data-group=${g.key}
315
- class=${cn(
316
- 'tk-group-head',
317
- g.key === 'overdue' && 'is-overdue',
318
- g.key === 'autoclosed' && 'is-autoclosed',
319
- isCollapsed && 'is-collapsed',
320
- )}
321
- onClick=${() => toggleGroup(g.key)}
322
- onKeyDown=${(e) => {
323
- if (e.key === 'Enter' || e.key === ' ') {
324
- e.preventDefault();
325
- toggleGroup(g.key);
326
- }
327
- }}
328
- >
329
- <span class="tk-group-label">
330
- <${Icon} name="chevron-down" size=${10} className="chev" />
331
- ${g.label}
332
- </span>
333
- <span class="ct">${g.tasks.length}</span>
334
- </div>
335
- ${!isCollapsed &&
336
- g.tasks.map((t) => html`
509
+ // Group head + rows are emitted FLAT (direct children of
510
+ // .tk-list), not wrapped in a per-group div — so the head's
511
+ // `position:sticky; top:0` pins to the scroll container and the
512
+ // next head pushes it up (matches the design's .ld-list). A
513
+ // wrapper div would scope each sticky to its own group bounds.
514
+ const head = html`
515
+ <div
516
+ key=${`${g.key}:head`}
517
+ role="button"
518
+ tabIndex=${0}
519
+ aria-expanded=${!isCollapsed}
520
+ data-group=${g.key}
521
+ class=${cn(
522
+ 'tk-group-head',
523
+ g.key === 'overdue' && 'is-overdue',
524
+ g.key === 'autoclosed' && 'is-autoclosed',
525
+ isCollapsed && 'is-collapsed',
526
+ )}
527
+ onClick=${() => toggleGroup(g.key)}
528
+ onKeyDown=${(e) => {
529
+ if (e.key === 'Enter' || e.key === ' ') {
530
+ e.preventDefault();
531
+ toggleGroup(g.key);
532
+ }
533
+ }}
534
+ >
535
+ <span class="tk-group-label">
536
+ <${Icon} name="chevron-down" size=${10} className="chev" />
537
+ ${g.label}
538
+ </span>
539
+ <span class="ct">${g.tasks.length}</span>
540
+ </div>
541
+ `;
542
+ if (isCollapsed) return [head];
543
+ return [
544
+ head,
545
+ ...g.tasks.map(
546
+ (t) => html`
337
547
  <${TaskRow}
338
548
  key=${t.id}
339
549
  task=${t}
340
550
  selected=${selectedId === t.id}
341
551
  onSelect=${setSelectedId}
342
552
  />
343
- `)}
344
- </div>
345
- `;
553
+ `,
554
+ ),
555
+ ];
346
556
  })}
347
557
  </div>
348
558
 
package/dist/index.html CHANGED
@@ -19,8 +19,20 @@
19
19
  font-family: var(--font-sans, system-ui, -apple-system, sans-serif);
20
20
  }
21
21
  * { box-sizing: border-box; }
22
- html, body, #root { height: 100%; }
23
- body { margin: 0; background: var(--tasks-bg); color: var(--tasks-fg); font: 14px/1.4 var(--font-sans, system-ui); -webkit-font-smoothing: antialiased; }
22
+ /* The page itself never scrolls — only the inner .tk-list (overflow-y:
23
+ auto) does. Without this, any leak in the flex height chain lets the
24
+ whole iframe document scroll, which also defeats sticky group headers
25
+ (they'd stick to the page, not the list). */
26
+ html, body, #root { height: 100%; overflow: hidden; }
27
+ /* `html body` (specificity 0,0,2), not `body` (0,0,1): app.js injects the
28
+ vendored app-kit-css AFTER this <style> (P3 inc-3), and its 00-base
29
+ `body {}` reset sets font-family:var(--font-body) (Inter) / 13px / 1.55.
30
+ The shipped Tasks pane has always rendered its inherited text in
31
+ var(--font-sans) (Plus Jakarta Sans) at 14px/1.4 — bumping the
32
+ specificity here keeps that base intact so adopting the kit introduces
33
+ no pane-wide font/size shift (the kit's box-sizing + margin resets are
34
+ duplicates of the rules above, so they stay no-ops). */
35
+ html body { margin: 0; background: var(--tasks-bg); color: var(--tasks-fg); font: 14px/1.4 var(--font-sans, system-ui); -webkit-font-smoothing: antialiased; }
24
36
  #root > .boot { padding: 1rem 1.25rem; font-size: 0.85rem; color: var(--tasks-muted); }
25
37
  </style>
26
38
  </head>