@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.
- package/dist/app.js +31 -74
- package/dist/features/tasks/create-task-form.js +159 -0
- package/dist/features/tasks/done-view.js +161 -0
- package/dist/features/tasks/sweeper-view.js +170 -0
- package/dist/features/tasks/task-detail-pane.js +92 -21
- package/dist/features/tasks/task-row.js +6 -6
- package/dist/features/tasks/tasks-view.js +307 -97
- package/dist/index.html +14 -2
- package/dist/lib/app-kit-css.js +3 -0
- package/dist/lib/assignees.js +137 -0
- package/dist/lib/bridge.js +24 -1
- package/dist/lib/esm-sh.d.ts +0 -4
- package/dist/lib/queries.js +89 -4
- package/dist/lib/shared.js +1 -1
- package/dist/lib/tasks-css.js +4 -5
- package/dist/lib/tokens-css.js +3 -5
- package/dist/lib/ui.js +5 -0
- package/dist/tasks.css +41 -313
- package/manifest.json +4 -11
- package/package.json +7 -4
- package/LICENSE +0 -201
- package/dist/features/tasks/view-tabs.js +0 -38
- package/dist/lib/supabase.js +0 -35
|
@@ -1,17 +1,73 @@
|
|
|
1
1
|
// Tasks main view — ported from views/TasksView.tsx. List + filter bar +
|
|
2
|
-
// grouped rows + master/detail split
|
|
3
|
-
//
|
|
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'
|
|
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
|
-
//
|
|
69
|
-
//
|
|
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
|
-
|
|
73
|
-
|
|
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
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
//
|
|
172
|
-
// session
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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="
|
|
201
|
-
<div class="
|
|
202
|
-
<div class="
|
|
203
|
-
<${Icon} name="check-square" size=${
|
|
204
|
-
<
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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=${
|
|
219
|
-
title=${isStandalone() ? 'Dispatch unavailable in standalone preview' : '
|
|
220
|
-
onClick=${
|
|
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=${
|
|
223
|
-
${
|
|
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
|
-
${
|
|
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
|
-
|
|
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
|
|
465
|
+
<select data-filter="owner" onChange=${(e) => setOwnerFilter(e.target.value)}>
|
|
254
466
|
<option value="">Anyone</option>
|
|
255
|
-
<option value
|
|
256
|
-
<option value
|
|
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
|
|
471
|
+
<select data-filter="category" onChange=${(e) => setCategoryFilter(e.target.value)}>
|
|
265
472
|
<option value="">All</option>
|
|
266
|
-
|
|
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
|
-
${
|
|
504
|
+
${!isLoading && !error && visibleGroups.length === 0 && html`
|
|
304
505
|
<div class="tk-empty-box">No tasks match.</div>
|
|
305
506
|
`}
|
|
306
|
-
${
|
|
507
|
+
${visibleGroups.flatMap((g) => {
|
|
307
508
|
const isCollapsed = collapsed.has(g.key);
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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>
|