@ikenga/pkg-tasks 0.2.0
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/LICENSE +201 -0
- package/README.md +100 -0
- package/dist/app.js +219 -0
- package/dist/features/tasks/agenda-view.js +113 -0
- package/dist/features/tasks/task-detail-pane.js +398 -0
- package/dist/features/tasks/task-row.js +70 -0
- package/dist/features/tasks/tasks-view.js +361 -0
- package/dist/features/tasks/triage-view.js +124 -0
- package/dist/features/tasks/view-tabs.js +38 -0
- package/dist/index.html +49 -0
- package/dist/lib/bridge.js +140 -0
- package/dist/lib/esm-sh.d.ts +39 -0
- package/dist/lib/queries.js +193 -0
- package/dist/lib/query-keys.js +14 -0
- package/dist/lib/shared.js +315 -0
- package/dist/lib/supabase.js +35 -0
- package/dist/lib/tasks-css.js +5 -0
- package/dist/lib/tokens-css.js +5 -0
- package/dist/lib/ui.js +102 -0
- package/dist/tasks.css +1046 -0
- package/manifest.json +53 -0
- package/package.json +24 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// MCP Apps SDK bridge — the canonical iframe⇄host protocol Ikenga uses.
|
|
2
|
+
//
|
|
3
|
+
// Pattern from @modelcontextprotocol/ext-apps Quickstart + the shell's
|
|
4
|
+
// pkg-iframe-host.tsx implementation:
|
|
5
|
+
// 1. new App(...) — register handlers before connect
|
|
6
|
+
// 2. await app.connect() — runs ui/initialize handshake automatically
|
|
7
|
+
// 3. app.getHostContext() — read theme / styles / supabase / royaltiAuth
|
|
8
|
+
// 4. app.callServerTool({ name: 'host.<x>', arguments }) — invoke host tools
|
|
9
|
+
// (shell intercepts `host.*` names in dispatchHostCall; everything else
|
|
10
|
+
// proxies to pkg MCP servers if any).
|
|
11
|
+
//
|
|
12
|
+
// The host re-emits hostContext on theme change via onhostcontextchanged.
|
|
13
|
+
|
|
14
|
+
// Use the bundled `app-with-deps` build — the default entry pulls
|
|
15
|
+
// `zod/v4` as a peer-via-esm.sh and dependency resolution sometimes
|
|
16
|
+
// produces a Zod build missing `.custom()`. The bundled variant
|
|
17
|
+
// inlines its deps so it works regardless of esm.sh's resolver state.
|
|
18
|
+
// NOTE: we deliberately do NOT import the SDK's applyDocumentTheme /
|
|
19
|
+
// applyHostStyleVariables / applyHostFonts helpers. Theme is owned by app.js's
|
|
20
|
+
// parent-<html> mirror (the artifact pattern). applyDocumentTheme in particular
|
|
21
|
+
// clobbers our workspace `data-theme` (A/B/C) with 'light'|'dark', breaking the
|
|
22
|
+
// bundled @ikenga/tokens palette — so the bridge stays out of theming entirely.
|
|
23
|
+
import { App } from 'https://esm.sh/@modelcontextprotocol/ext-apps@1.7.1/app-with-deps';
|
|
24
|
+
|
|
25
|
+
let app = null;
|
|
26
|
+
|
|
27
|
+
export async function connectBridge({ name, version, onContextChange }) {
|
|
28
|
+
app = new App({ name, version }, {
|
|
29
|
+
// Capabilities the pkg advertises to the host. Keep minimal — declare only
|
|
30
|
+
// what we actually use.
|
|
31
|
+
tools: { listChanged: false },
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
app.onerror = (err) => console.error('[tasks] bridge error', err);
|
|
35
|
+
// Theme is NOT applied here — app.js mirrors it from the parent <html>.
|
|
36
|
+
// We still forward context so live Supabase/auth updates reach the app.
|
|
37
|
+
app.onhostcontextchanged = (ctx) => {
|
|
38
|
+
onContextChange?.(ctx);
|
|
39
|
+
};
|
|
40
|
+
app.onteardown = async () => ({});
|
|
41
|
+
|
|
42
|
+
await app.connect();
|
|
43
|
+
return app.getHostContext();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Navigate the focused shell pane (cross-pkg or in-pkg sub-route). */
|
|
47
|
+
export async function hostNavigate(path) {
|
|
48
|
+
if (!app) throw new Error('bridge not connected');
|
|
49
|
+
return app.callServerTool({
|
|
50
|
+
name: 'host.navigate',
|
|
51
|
+
arguments: { path },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Open an external link via the host. */
|
|
56
|
+
export async function openLink(url) {
|
|
57
|
+
if (!app) throw new Error('bridge not connected');
|
|
58
|
+
return app.openLink({ url });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Publish the pkg's sidebar menu to the shell. PkgMode renders these items in
|
|
62
|
+
* the left side panel when this pkg's pane is focused (normal AppMode). Item
|
|
63
|
+
* clicks come back as hostContext changes via `royaltiSuite.activeFeature` —
|
|
64
|
+
* listen via onContextChange in connectBridge.
|
|
65
|
+
* items: [{ id, label, icon?, badge? }] */
|
|
66
|
+
export async function setMenu(items) {
|
|
67
|
+
if (!app) throw new Error('bridge not connected');
|
|
68
|
+
return app.callServerTool({
|
|
69
|
+
name: 'host.pkg.setMenu',
|
|
70
|
+
arguments: { items },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Seed a user turn into the shell's active Claude session. This is how the
|
|
76
|
+
* Tasks pkg "creates" work: anon RLS only grants UPDATE of status/completed_at
|
|
77
|
+
* (never INSERT), so a new task can't be written client-side. Instead we
|
|
78
|
+
* dispatch a natural-language request to the agent, which creates the task via
|
|
79
|
+
* its privileged path. Verb confirmed in shell/src/components/pkg/
|
|
80
|
+
* pkg-iframe-host.tsx (`host.sendToActiveSession`).
|
|
81
|
+
*
|
|
82
|
+
* prompt: string — the instruction shown as the user turn
|
|
83
|
+
* source?: string — provenance tag (defaults to the pkg id)
|
|
84
|
+
*/
|
|
85
|
+
export async function hostSendToActiveSession(prompt, source = 'com.ikenga.tasks') {
|
|
86
|
+
if (!app) throw new Error('bridge not connected');
|
|
87
|
+
return app.callServerTool({
|
|
88
|
+
name: 'host.sendToActiveSession',
|
|
89
|
+
arguments: { prompt, source },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Dispatch a structured PA action through the host. Kept as a thin alias for
|
|
95
|
+
* forward-compat: if/when the shell exposes a dedicated `host.paActionsRun`
|
|
96
|
+
* verb, point this at it. Today the shell does NOT expose that verb (only
|
|
97
|
+
* host.navigate / host.sendToActiveSession / host.openSessionDialog /
|
|
98
|
+
* host.pkg.setMenu exist), so the create path uses hostSendToActiveSession.
|
|
99
|
+
*/
|
|
100
|
+
export async function hostPaActionsRun(args) {
|
|
101
|
+
if (!app) throw new Error('bridge not connected');
|
|
102
|
+
return app.callServerTool({
|
|
103
|
+
name: 'host.paActionsRun',
|
|
104
|
+
arguments: args,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Read the local `pa.db` via the host's `host.dbQuery` verb (WP-04 read-swap).
|
|
110
|
+
* SELECT/WITH only — the shell rejects writes and gates this on the pkg
|
|
111
|
+
* declaring `capabilities.sqlite`. Returns the row array
|
|
112
|
+
* (`structuredContent.rows`); throws on a closed/failed bridge so callers can
|
|
113
|
+
* surface the error in the query layer. Requires a connected bridge — there is
|
|
114
|
+
* no standalone fallback (reads no longer go through supabase-js).
|
|
115
|
+
*
|
|
116
|
+
* sql: string — a single SELECT/WITH statement with `?` params
|
|
117
|
+
* params: SqlValue[] — positional bind values
|
|
118
|
+
*/
|
|
119
|
+
export async function hostDbQuery(sql, params = []) {
|
|
120
|
+
if (!app) throw new Error('[tasks] bridge not connected — db_query unavailable');
|
|
121
|
+
const res = await app.callServerTool({
|
|
122
|
+
name: 'host.dbQuery',
|
|
123
|
+
arguments: { sql, params },
|
|
124
|
+
});
|
|
125
|
+
const sc = res?.structuredContent;
|
|
126
|
+
if (!sc || sc.ok !== true) {
|
|
127
|
+
throw new Error(sc?.error ?? res?.content?.[0]?.text ?? 'host.dbQuery failed');
|
|
128
|
+
}
|
|
129
|
+
return Array.isArray(sc.rows) ? sc.rows : [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Read the current hostContext snapshot. */
|
|
133
|
+
export function getContext() {
|
|
134
|
+
return app?.getHostContext() ?? null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Detect standalone-dev (no parent shell). */
|
|
138
|
+
export function isStandalone() {
|
|
139
|
+
return typeof window !== 'undefined' && window.parent === window;
|
|
140
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Dev-only ambient declarations so `tsc -p tsconfig.dev.json --checkJs` can
|
|
2
|
+
// resolve the https://esm.sh/* import URLs (TypeScript cannot fetch them).
|
|
3
|
+
// NEVER referenced by the publish path — the browser resolves these natively.
|
|
4
|
+
|
|
5
|
+
declare module 'https://esm.sh/react@19.0.0' {
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
7
|
+
const React: any;
|
|
8
|
+
export = React;
|
|
9
|
+
}
|
|
10
|
+
declare module 'https://esm.sh/react-dom@19.0.0/client' {
|
|
11
|
+
export const createRoot: (el: Element | null) => { render: (node: unknown) => void };
|
|
12
|
+
}
|
|
13
|
+
declare module 'https://esm.sh/htm@3.1.1' {
|
|
14
|
+
const htm: { bind: (h: unknown) => (...args: unknown[]) => unknown };
|
|
15
|
+
export default htm;
|
|
16
|
+
}
|
|
17
|
+
declare module 'https://esm.sh/@tanstack/react-query@5?deps=react@19.0.0' {
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
19
|
+
export const QueryClient: any;
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
21
|
+
export const QueryClientProvider: any;
|
|
22
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
23
|
+
export const useQuery: any;
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
25
|
+
export const useMutation: any;
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
27
|
+
export const useQueryClient: any;
|
|
28
|
+
}
|
|
29
|
+
declare module 'https://esm.sh/@supabase/supabase-js@2' {
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
31
|
+
export const createClient: any;
|
|
32
|
+
}
|
|
33
|
+
declare module 'https://esm.sh/@modelcontextprotocol/ext-apps@1.7.1/app-with-deps' {
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
35
|
+
export const App: any;
|
|
36
|
+
export const applyDocumentTheme: (theme: unknown) => void;
|
|
37
|
+
export const applyHostStyleVariables: (vars: unknown) => void;
|
|
38
|
+
export const applyHostFonts: (fonts: unknown) => void;
|
|
39
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Query layer — ported from src/lib/queries/tasks.ts. JSDoc carries the TS
|
|
2
|
+
// types. Schema verified against the local pa.db `tasks` table (shell
|
|
3
|
+
// migration 0025_tasks_domain.sql): every selected column exists.
|
|
4
|
+
//
|
|
5
|
+
// WP-04 read-swap: reads go through the host's `host.dbQuery` verb (local
|
|
6
|
+
// pa.db) instead of an in-iframe supabase-js client. The status-update WRITE
|
|
7
|
+
// (task-detail-pane.js) still uses supabase-js — moving it needs a host write
|
|
8
|
+
// verb that does not exist yet (follow-up WP).
|
|
9
|
+
|
|
10
|
+
import { hostDbQuery } from './bridge.js';
|
|
11
|
+
import { queryKeys } from './query-keys.js';
|
|
12
|
+
|
|
13
|
+
// pa.db stores former Postgres array/json columns as TEXT (the Pg→SQLite
|
|
14
|
+
// down-map, shell migration 0025). `tags` arrives as a string, not a JS array
|
|
15
|
+
// — normalize it back so the Task shape matches the JSDoc + the detail pane's
|
|
16
|
+
// `.map`/`.length` usage. JSON first (the canonical ETL encoding), then a
|
|
17
|
+
// comma split as a tolerant fallback, then [].
|
|
18
|
+
/** @param {any} row */
|
|
19
|
+
function normalizeTaskRow(row) {
|
|
20
|
+
if (!row || typeof row !== 'object') return row;
|
|
21
|
+
const t = row.tags;
|
|
22
|
+
if (typeof t === 'string') {
|
|
23
|
+
const s = t.trim();
|
|
24
|
+
if (!s) {
|
|
25
|
+
row.tags = null;
|
|
26
|
+
} else {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(s);
|
|
29
|
+
row.tags = Array.isArray(parsed) ? parsed : [String(parsed)];
|
|
30
|
+
} catch {
|
|
31
|
+
row.tags = s.split(',').map((x) => x.trim()).filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return row;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @typedef {'pending'|'in_progress'|'completed'|'cancelled'|'blocked'} TaskStatus */
|
|
39
|
+
/** @typedef {'critical'|'high'|'medium'|'low'} TaskPriority */
|
|
40
|
+
/** @typedef {'human'|'agent'} AssigneeType */
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @typedef {Object} Task
|
|
44
|
+
* @property {string} id
|
|
45
|
+
* @property {string} title
|
|
46
|
+
* @property {string|null} description
|
|
47
|
+
* @property {TaskStatus} status
|
|
48
|
+
* @property {TaskPriority} priority
|
|
49
|
+
* @property {string|null} assigned_to
|
|
50
|
+
* @property {AssigneeType|null} assignee_type
|
|
51
|
+
* @property {string|null} category
|
|
52
|
+
* @property {string[]|null} tags
|
|
53
|
+
* @property {string|null} due_date
|
|
54
|
+
* @property {string|null} completed_at
|
|
55
|
+
* @property {string} created_at
|
|
56
|
+
* @property {string} updated_at
|
|
57
|
+
* @property {number|null} progress_pct
|
|
58
|
+
* @property {string|null} outcome_notes
|
|
59
|
+
* @property {string|null} parent_task_id
|
|
60
|
+
* @property {string|null} blocked_by_task_id
|
|
61
|
+
* @property {string|null} source_email_id
|
|
62
|
+
* @property {string|null} agent_source
|
|
63
|
+
* @property {string|null} initiative_id
|
|
64
|
+
* @property {string|null} risk_id
|
|
65
|
+
* @property {string|null} effort_estimate
|
|
66
|
+
* @property {'autonomous'|'report'|'approval_required'|null} execution_mode
|
|
67
|
+
* @property {string|null} task_result
|
|
68
|
+
* @property {string|null} claude_session_id
|
|
69
|
+
* @property {string|null} working_dir
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
export const TASKS_LIST_COLUMNS =
|
|
73
|
+
'id, title, description, status, priority, assigned_to, assignee_type, category, due_date, created_at, updated_at, progress_pct, outcome_notes, execution_mode';
|
|
74
|
+
|
|
75
|
+
/** @type {readonly TaskStatus[]} */
|
|
76
|
+
const ACTIVE_STATUSES = ['pending', 'in_progress', 'blocked'];
|
|
77
|
+
const STALE_DAYS = 7;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @typedef {Object} TriageCounts
|
|
81
|
+
* @property {number} overdue
|
|
82
|
+
* @property {number} stale
|
|
83
|
+
* @property {number} unassigned
|
|
84
|
+
* @property {number} blocked
|
|
85
|
+
* @property {number} needsAttention Deduplicated overdue-OR-unassigned badge total.
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Server-side health counts for the Triage badge (R16-followup: server-side,
|
|
90
|
+
* so the badge is correct independent of the list's filter + 200-row cap).
|
|
91
|
+
* Four `head:true` count() selects, run in parallel (+ the deduped total).
|
|
92
|
+
*/
|
|
93
|
+
export function triageCountsQuery() {
|
|
94
|
+
return {
|
|
95
|
+
queryKey: queryKeys.tasks.triageCounts(),
|
|
96
|
+
/** @returns {Promise<TriageCounts>} */
|
|
97
|
+
queryFn: async () => {
|
|
98
|
+
// "Overdue" = due before the start of today, matching the app's own
|
|
99
|
+
// convention (groupTasks / dueLabel treat a task due *today* as "Today",
|
|
100
|
+
// not overdue, until the day rolls over).
|
|
101
|
+
const startOfTodayIso = (() => {
|
|
102
|
+
const d = new Date();
|
|
103
|
+
d.setHours(0, 0, 0, 0);
|
|
104
|
+
return d.toISOString();
|
|
105
|
+
})();
|
|
106
|
+
const staleIso = new Date(
|
|
107
|
+
Date.now() - STALE_DAYS * 24 * 60 * 60 * 1000,
|
|
108
|
+
).toISOString();
|
|
109
|
+
// `?,?,?` placeholders for the active-status set, reused per count.
|
|
110
|
+
const activePlaceholders = ACTIVE_STATUSES.map(() => '?').join(',');
|
|
111
|
+
const countOne = async (where, params) => {
|
|
112
|
+
const rows = await hostDbQuery(
|
|
113
|
+
`SELECT count(*) AS n FROM tasks WHERE ${where}`,
|
|
114
|
+
params,
|
|
115
|
+
);
|
|
116
|
+
return Number(rows[0]?.n ?? 0);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const [overdue, stale, unassigned, blocked, needsAttention] = await Promise.all([
|
|
120
|
+
countOne(`status IN (${activePlaceholders}) AND due_date < ?`, [
|
|
121
|
+
...ACTIVE_STATUSES,
|
|
122
|
+
startOfTodayIso,
|
|
123
|
+
]),
|
|
124
|
+
countOne(`status IN (${activePlaceholders}) AND updated_at < ?`, [
|
|
125
|
+
...ACTIVE_STATUSES,
|
|
126
|
+
staleIso,
|
|
127
|
+
]),
|
|
128
|
+
countOne(`status IN (${activePlaceholders}) AND assigned_to IS NULL`, [
|
|
129
|
+
...ACTIVE_STATUSES,
|
|
130
|
+
]),
|
|
131
|
+
countOne(`status = ?`, ['blocked']),
|
|
132
|
+
// overdue OR unassigned, counted once (the deduplicated badge total).
|
|
133
|
+
countOne(
|
|
134
|
+
`status IN (${activePlaceholders}) AND (due_date < ? OR assigned_to IS NULL)`,
|
|
135
|
+
[...ACTIVE_STATUSES, startOfTodayIso],
|
|
136
|
+
),
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
overdue,
|
|
141
|
+
stale,
|
|
142
|
+
unassigned,
|
|
143
|
+
blocked,
|
|
144
|
+
needsAttention,
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** @param {string} id */
|
|
151
|
+
export function taskDetailQuery(id) {
|
|
152
|
+
return {
|
|
153
|
+
queryKey: queryKeys.tasks.detail(id),
|
|
154
|
+
/** @returns {Promise<Task|null>} */
|
|
155
|
+
queryFn: async () => {
|
|
156
|
+
const rows = await hostDbQuery('SELECT * FROM tasks WHERE id = ? LIMIT 1', [id]);
|
|
157
|
+
if (rows.length === 0) return null;
|
|
158
|
+
return /** @type {Task} */ (normalizeTaskRow(rows[0]));
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** @param {string} parentId */
|
|
164
|
+
export function subtasksQuery(parentId) {
|
|
165
|
+
return {
|
|
166
|
+
queryKey: queryKeys.tasks.subtasks(parentId),
|
|
167
|
+
/** @returns {Promise<Task[]>} */
|
|
168
|
+
queryFn: async () => {
|
|
169
|
+
const rows = await hostDbQuery(
|
|
170
|
+
'SELECT * FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC',
|
|
171
|
+
[parentId],
|
|
172
|
+
);
|
|
173
|
+
return /** @type {Task[]} */ (rows.map(normalizeTaskRow));
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** @param {string|null} blockingId */
|
|
179
|
+
export function blockingTaskQuery(blockingId) {
|
|
180
|
+
return {
|
|
181
|
+
queryKey: queryKeys.tasks.detail(blockingId ?? 'none'),
|
|
182
|
+
/** @returns {Promise<Task|null>} */
|
|
183
|
+
queryFn: async () => {
|
|
184
|
+
if (!blockingId) return null;
|
|
185
|
+
const rows = await hostDbQuery('SELECT * FROM tasks WHERE id = ? LIMIT 1', [
|
|
186
|
+
blockingId,
|
|
187
|
+
]);
|
|
188
|
+
if (rows.length === 0) return null;
|
|
189
|
+
return /** @type {Task} */ (normalizeTaskRow(rows[0]));
|
|
190
|
+
},
|
|
191
|
+
enabled: !!blockingId,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// TanStack Query cache keys — ported from src/lib/query-keys.ts.
|
|
2
|
+
|
|
3
|
+
export const queryKeys = {
|
|
4
|
+
tasks: {
|
|
5
|
+
all: ['tasks'],
|
|
6
|
+
/** @param {string} filter */
|
|
7
|
+
list: (filter) => [...queryKeys.tasks.all, 'list', filter],
|
|
8
|
+
/** @param {string} id */
|
|
9
|
+
detail: (id) => [...queryKeys.tasks.all, 'detail', id],
|
|
10
|
+
/** @param {string} parentId */
|
|
11
|
+
subtasks: (parentId) => [...queryKeys.tasks.all, 'subtasks', parentId],
|
|
12
|
+
triageCounts: () => [...queryKeys.tasks.all, 'triage-counts'],
|
|
13
|
+
},
|
|
14
|
+
};
|