@polderlabs/bizar 4.4.13 → 4.5.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/bizar-dash/CHANGELOG.md +37 -276
- package/bizar-dash/dist/assets/main-CDFKHzBg.css +1 -0
- package/bizar-dash/dist/assets/main-NYFpS2wY.js +312 -0
- package/bizar-dash/dist/assets/main-NYFpS2wY.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile--0FBIKX3.js} +2 -2
- package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile--0FBIKX3.js.map} +1 -1
- package/bizar-dash/dist/assets/mobile-OgRp8VIb.js +352 -0
- package/bizar-dash/dist/assets/mobile-OgRp8VIb.js.map +1 -0
- package/bizar-dash/dist/index.html +3 -3
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
- package/bizar-dash/skills/bizar/SKILL.md +96 -0
- package/bizar-dash/skills/chat/SKILL.md +74 -0
- package/bizar-dash/skills/lightrag/SKILL.md +75 -0
- package/bizar-dash/skills/minimax/SKILL.md +80 -0
- package/bizar-dash/skills/obsidian/SKILL.md +55 -0
- package/bizar-dash/skills/providers/SKILL.md +75 -0
- package/bizar-dash/skills/sdk/SKILL.md +138 -0
- package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
- package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
- package/bizar-dash/skills/usage/SKILL.md +62 -0
- package/bizar-dash/src/server/api.mjs +12 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +5 -2
- package/bizar-dash/src/server/memory-store.mjs +38 -0
- package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
- package/bizar-dash/src/server/minimax.mjs +196 -5
- package/bizar-dash/src/server/providers-store.mjs +956 -0
- package/bizar-dash/src/server/routes/config.mjs +52 -1
- package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
- package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
- package/bizar-dash/src/server/routes/memory.mjs +241 -1
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
- package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
- package/bizar-dash/src/server/routes/providers.mjs +266 -5
- package/bizar-dash/src/server/routes/skills.mjs +32 -43
- package/bizar-dash/src/server/routes/update.mjs +340 -0
- package/bizar-dash/src/server/routes/usage.mjs +136 -0
- package/bizar-dash/src/server/serve-info.mjs +135 -4
- package/bizar-dash/src/server/server.mjs +4 -0
- package/bizar-dash/src/server/skills-store.mjs +152 -262
- package/bizar-dash/src/web/App.tsx +118 -29
- package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
- package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
- package/bizar-dash/src/web/components/Topbar.tsx +0 -1
- package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
- package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
- package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
- package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
- package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
- package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
- package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
- package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
- package/bizar-dash/src/web/lib/api.ts +43 -0
- package/bizar-dash/src/web/main.tsx +1 -0
- package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
- package/bizar-dash/src/web/styles/chat.css +135 -1
- package/bizar-dash/src/web/styles/main.css +46 -0
- package/bizar-dash/src/web/styles/minimax-usage.css +335 -0
- package/bizar-dash/src/web/styles/settings.css +418 -0
- package/bizar-dash/src/web/styles/skills.css +302 -0
- package/bizar-dash/src/web/styles/tasks.css +288 -0
- package/bizar-dash/src/web/views/Chat.tsx +276 -48
- package/bizar-dash/src/web/views/Config.tsx +3 -2065
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
- package/bizar-dash/src/web/views/Settings.tsx +6 -0
- package/bizar-dash/src/web/views/Skills.tsx +208 -260
- package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
- package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
- package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
- package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
- package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
- package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
- package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
- package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
- package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
- package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
- package/bizar-dash/tests/skills-list.test.mjs +232 -0
- package/bizar-dash/tests/skills-search.test.mjs +222 -0
- package/bizar-dash/tests/tasks-create.test.mjs +187 -0
- package/bizar-dash/tests/update-check.test.mjs +127 -0
- package/bizar-dash/tests/update-run.test.mjs +266 -0
- package/cli/bin.mjs +82 -1
- package/cli/provision.mjs +118 -4
- package/config/agents/_shared/SKILLS.md +109 -0
- package/package.json +1 -1
- package/bizar-dash/dist/assets/main-BB5mJurD.js +0 -352
- package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
- package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
- package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
- package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
|
@@ -1,4 +1,25 @@
|
|
|
1
|
-
// src/views/Tasks.tsx
|
|
1
|
+
// src/views/Tasks.tsx
|
|
2
|
+
//
|
|
3
|
+
// v4.6.0 — Simplified task board.
|
|
4
|
+
//
|
|
5
|
+
// Design goals (per Odin):
|
|
6
|
+
// - Create flow = title + description (+ optional priority hint + tags).
|
|
7
|
+
// No agent picker. Odin decides what to do with the task and which
|
|
8
|
+
// agent picks it up; the UI just records intent.
|
|
9
|
+
// - Tasks grouped by status into 5 columns:
|
|
10
|
+
// Todo (api: queued)
|
|
11
|
+
// In progress (api: doing)
|
|
12
|
+
// Done (api: done)
|
|
13
|
+
// Failed (api: blocked)
|
|
14
|
+
// Backlog (api: backlog, parked ideas)
|
|
15
|
+
// - Each task row shows title, status, priority, createdAt, and the
|
|
16
|
+
// agent that auto-claimed it (read-only — set by the orchestrator).
|
|
17
|
+
// - Per-task actions: edit, delete, retry, change status.
|
|
18
|
+
// - Backlog view: parked ideas; one-click "promote to Todo".
|
|
19
|
+
//
|
|
20
|
+
// We keep the API contract (statuses: queued/doing/done/blocked/backlog)
|
|
21
|
+
// so the dashboard doesn't break the existing server, tasks store, and
|
|
22
|
+
// tests. The UI maps API statuses to friendlier labels.
|
|
2
23
|
import { useEffect, useMemo, useState } from 'react';
|
|
3
24
|
import {
|
|
4
25
|
CheckSquare,
|
|
@@ -9,43 +30,24 @@ import {
|
|
|
9
30
|
Search,
|
|
10
31
|
X as XIcon,
|
|
11
32
|
Clock,
|
|
12
|
-
PlayCircle,
|
|
13
|
-
PauseCircle,
|
|
14
|
-
MessageSquare,
|
|
15
|
-
Activity,
|
|
16
|
-
Link2,
|
|
17
|
-
Calendar,
|
|
18
|
-
Paperclip,
|
|
19
33
|
Tag as TagIcon,
|
|
20
|
-
Archive,
|
|
21
34
|
ArchiveRestore,
|
|
22
|
-
Filter,
|
|
23
|
-
ArrowUpDown,
|
|
24
|
-
Square,
|
|
25
|
-
CheckSquare as CheckSquareIcon,
|
|
26
|
-
Bot,
|
|
27
|
-
RotateCw,
|
|
28
|
-
AlertOctagon,
|
|
29
|
-
Sparkles,
|
|
30
35
|
RefreshCw,
|
|
31
36
|
Send,
|
|
32
|
-
FileText,
|
|
33
37
|
Inbox,
|
|
38
|
+
Edit2,
|
|
39
|
+
RotateCw,
|
|
40
|
+
Bot,
|
|
34
41
|
} from 'lucide-react';
|
|
35
42
|
import { Button } from '../components/Button';
|
|
36
|
-
import { Card, CardTitle } from '../components/Card';
|
|
37
|
-
import { EmptyState } from '../components/EmptyState';
|
|
43
|
+
import { Card, CardTitle, CardMeta } from '../components/Card';
|
|
38
44
|
import { Spinner } from '../components/Spinner';
|
|
39
|
-
import { StatusBadge } from '../components/StatusBadge';
|
|
40
45
|
import { Tag } from '../components/Tag';
|
|
41
46
|
import { useModal } from '../components/Modal';
|
|
42
47
|
import { useToast } from '../components/Toast';
|
|
43
|
-
import { BgStatusBadge } from '../components/BgStatusBadge';
|
|
44
|
-
import { openKillConfirmDialog } from '../components/KillConfirmDialog';
|
|
45
48
|
import { api, enhancePrompt } from '../lib/api';
|
|
46
|
-
import { cn, formatRelative, priorityColors
|
|
47
|
-
import type {
|
|
48
|
-
import { openArtifactViewer } from '../components/ArtifactViewer';
|
|
49
|
+
import { cn, formatRelative, priorityColors } from '../lib/utils';
|
|
50
|
+
import type { Settings, Snapshot, Task } from '../lib/types';
|
|
49
51
|
import { BacklogPanel } from '../components/tasks/BacklogPanel';
|
|
50
52
|
|
|
51
53
|
type Props = {
|
|
@@ -56,41 +58,23 @@ type Props = {
|
|
|
56
58
|
refreshSnapshot: () => Promise<void>;
|
|
57
59
|
};
|
|
58
60
|
|
|
59
|
-
type
|
|
60
|
-
id: Task['status'] | string;
|
|
61
|
-
label: string;
|
|
62
|
-
kind: 'info' | 'accent' | 'success' | 'warning' | 'error';
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
const COLUMNS: Column[] = [
|
|
66
|
-
{ id: 'queued', label: 'Queued', kind: 'info' },
|
|
67
|
-
{ id: 'doing', label: 'Doing', kind: 'accent' },
|
|
68
|
-
{ id: 'blocked', label: 'Blocked', kind: 'warning' },
|
|
69
|
-
{ id: 'done', label: 'Done', kind: 'success' },
|
|
70
|
-
];
|
|
61
|
+
type StatusKind = 'info' | 'accent' | 'success' | 'warning' | 'error';
|
|
71
62
|
|
|
72
63
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
64
|
+
* UI <-> API status mapping. We keep the existing API enum
|
|
65
|
+
* (queued | doing | done | blocked | archived | backlog) and only
|
|
66
|
+
* rename the labels here so the dashboard, server, and tests stay in
|
|
67
|
+
* sync. Switch back if you need to expose the raw API value.
|
|
77
68
|
*/
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
69
|
+
const COLUMNS: { id: Task['status']; label: string; kind: StatusKind }[] = [
|
|
70
|
+
{ id: 'queued', label: 'Todo', kind: 'info' },
|
|
71
|
+
{ id: 'doing', label: 'In progress', kind: 'accent' },
|
|
72
|
+
{ id: 'done', label: 'Done', kind: 'success' },
|
|
73
|
+
{ id: 'blocked', label: 'Failed', kind: 'error' },
|
|
74
|
+
];
|
|
83
75
|
|
|
84
76
|
const PRIORITIES = ['low', 'normal', 'high'] as const;
|
|
85
77
|
type Priority = (typeof PRIORITIES)[number];
|
|
86
|
-
type SortKey = 'priority' | 'due' | 'created' | 'updated';
|
|
87
|
-
|
|
88
|
-
const SORTS: { id: SortKey; label: string }[] = [
|
|
89
|
-
{ id: 'updated', label: 'Last updated' },
|
|
90
|
-
{ id: 'created', label: 'Created' },
|
|
91
|
-
{ id: 'priority', label: 'Priority' },
|
|
92
|
-
{ id: 'due', label: 'Due date' },
|
|
93
|
-
];
|
|
94
78
|
|
|
95
79
|
export function Tasks({ snapshot, refreshSnapshot, setActiveTab }: Props) {
|
|
96
80
|
const toast = useToast();
|
|
@@ -98,18 +82,13 @@ export function Tasks({ snapshot, refreshSnapshot, setActiveTab }: Props) {
|
|
|
98
82
|
const [tasks, setTasks] = useState<Task[]>(snapshot.tasks || []);
|
|
99
83
|
const [loading, setLoading] = useState(!snapshot.tasks);
|
|
100
84
|
const [filter, setFilter] = useState('');
|
|
101
|
-
const [assigneeFilter, setAssigneeFilter] = useState<string>('');
|
|
102
85
|
const [priorityFilter, setPriorityFilter] = useState<string>('');
|
|
103
|
-
const [
|
|
104
|
-
const [sortKey, setSortKey] = useState<SortKey>('updated');
|
|
105
|
-
const [showArchived, setShowArchived] = useState(false);
|
|
106
|
-
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
86
|
+
const [showBacklog, setShowBacklog] = useState(false);
|
|
107
87
|
const [tick, setTick] = useState(0); // for live timer re-render
|
|
108
88
|
|
|
109
|
-
const reload = async (
|
|
89
|
+
const reload = async () => {
|
|
110
90
|
try {
|
|
111
|
-
const
|
|
112
|
-
const data = await api.get<Task[]>(`/tasks${params}`);
|
|
91
|
+
const data = await api.get<Task[]>('/tasks');
|
|
113
92
|
setTasks(Array.isArray(data) ? data : []);
|
|
114
93
|
} catch (err) {
|
|
115
94
|
toast.error(`Tasks load failed: ${(err as Error).message}`);
|
|
@@ -125,67 +104,40 @@ export function Tasks({ snapshot, refreshSnapshot, setActiveTab }: Props) {
|
|
|
125
104
|
}
|
|
126
105
|
}, [snapshot.tasks]);
|
|
127
106
|
|
|
128
|
-
// Periodic tick
|
|
107
|
+
// Periodic tick keeps relative-time labels fresh without a full reload.
|
|
129
108
|
useEffect(() => {
|
|
130
|
-
const id = setInterval(() => setTick((t) => t + 1),
|
|
109
|
+
const id = setInterval(() => setTick((t) => t + 1), 30_000);
|
|
131
110
|
return () => clearInterval(id);
|
|
132
111
|
}, []);
|
|
133
112
|
|
|
134
|
-
const allTags = useMemo(() => {
|
|
135
|
-
const set = new Set<string>();
|
|
136
|
-
for (const t of tasks) for (const tag of t.tags || []) set.add(tag);
|
|
137
|
-
return Array.from(set).sort();
|
|
138
|
-
}, [tasks]);
|
|
139
|
-
|
|
140
113
|
const filtered = useMemo(() => {
|
|
141
|
-
let out = tasks;
|
|
142
|
-
if (assigneeFilter) {
|
|
143
|
-
out = out.filter((t) => (t.assignee || '') === assigneeFilter);
|
|
144
|
-
}
|
|
114
|
+
let out = tasks.filter((t) => t.status !== 'backlog');
|
|
145
115
|
if (priorityFilter) {
|
|
146
116
|
out = out.filter((t) => (t.priority || 'normal') === priorityFilter);
|
|
147
117
|
}
|
|
148
|
-
if (tagFilter) {
|
|
149
|
-
out = out.filter((t) => (t.tags || []).includes(tagFilter));
|
|
150
|
-
}
|
|
151
118
|
if (filter.trim()) {
|
|
152
119
|
const q = filter.toLowerCase();
|
|
153
|
-
out = out.filter((t) =>
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
return title.includes(q) || desc.includes(q) || tags.includes(q);
|
|
158
|
-
});
|
|
120
|
+
out = out.filter((t) =>
|
|
121
|
+
(t.title || '').toLowerCase().includes(q) ||
|
|
122
|
+
(t.description || '').toLowerCase().includes(q),
|
|
123
|
+
);
|
|
159
124
|
}
|
|
160
125
|
return out;
|
|
161
|
-
|
|
162
|
-
}, [tasks, filter, assigneeFilter, priorityFilter, tagFilter]);
|
|
126
|
+
}, [tasks, filter, priorityFilter]);
|
|
163
127
|
|
|
164
128
|
const sorted = useMemo(() => {
|
|
165
|
-
const out = [...filtered];
|
|
166
129
|
const priorityWeight = { high: 0, normal: 1, low: 2 } as Record<string, number>;
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
return ax - bx;
|
|
179
|
-
});
|
|
180
|
-
break;
|
|
181
|
-
case 'updated':
|
|
182
|
-
default:
|
|
183
|
-
out.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
|
184
|
-
break;
|
|
185
|
-
}
|
|
186
|
-
return out;
|
|
187
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
188
|
-
}, [filtered, sortKey]);
|
|
130
|
+
return [...filtered].sort((a, b) => {
|
|
131
|
+
const pa = priorityWeight[a.priority] ?? 1;
|
|
132
|
+
const pb = priorityWeight[b.priority] ?? 1;
|
|
133
|
+
if (pa !== pb) return pa - pb;
|
|
134
|
+
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
|
135
|
+
});
|
|
136
|
+
}, [filtered]);
|
|
137
|
+
|
|
138
|
+
const backlogCount = tasks.filter((t) => t.status === 'backlog').length;
|
|
139
|
+
|
|
140
|
+
// ─── Actions ─────────────────────────────────────────────────────────────
|
|
189
141
|
|
|
190
142
|
const moveTask = async (taskId: string, newStatus: string) => {
|
|
191
143
|
const t = tasks.find((x) => x.id === taskId);
|
|
@@ -198,7 +150,7 @@ export function Tasks({ snapshot, refreshSnapshot, setActiveTab }: Props) {
|
|
|
198
150
|
await api.patch(`/tasks/${encodeURIComponent(taskId)}/status`, {
|
|
199
151
|
status: newStatus,
|
|
200
152
|
});
|
|
201
|
-
toast.success(`Moved to ${newStatus}.`,
|
|
153
|
+
toast.success(`Moved to ${newStatus}.`, 1200);
|
|
202
154
|
} catch (err) {
|
|
203
155
|
setTasks((cur) =>
|
|
204
156
|
cur.map((x) => (x.id === taskId ? { ...x, status: prev } : x)),
|
|
@@ -212,119 +164,57 @@ export function Tasks({ snapshot, refreshSnapshot, setActiveTab }: Props) {
|
|
|
212
164
|
try {
|
|
213
165
|
await api.del(`/tasks/${encodeURIComponent(taskId)}`);
|
|
214
166
|
setTasks((cur) => cur.filter((t) => t.id !== taskId));
|
|
215
|
-
toast.success('Task deleted.',
|
|
167
|
+
toast.success('Task deleted.', 1200);
|
|
216
168
|
} catch (err) {
|
|
217
169
|
toast.error(`Delete failed: ${(err as Error).message}`);
|
|
218
170
|
}
|
|
219
171
|
};
|
|
220
172
|
|
|
221
|
-
const
|
|
173
|
+
const retryTask = async (taskId: string) => {
|
|
222
174
|
try {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const unarchiveTask = async (taskId: string) => {
|
|
232
|
-
try {
|
|
233
|
-
const updated = await api.post<Task>(`/tasks/${encodeURIComponent(taskId)}/unarchive`);
|
|
234
|
-
setTasks((cur) => cur.map((t) => (t.id === taskId ? updated : t)));
|
|
235
|
-
toast.success('Task restored.', 1500);
|
|
236
|
-
} catch (err) {
|
|
237
|
-
toast.error(`Restore failed: ${(err as Error).message}`);
|
|
238
|
-
}
|
|
239
|
-
};
|
|
240
|
-
|
|
241
|
-
const onBulkAction = async (action: 'archive' | 'delete' | 'move' | 'priority' | 'assign' | 'tag', params: Record<string, unknown> = {}) => {
|
|
242
|
-
if (selected.size === 0) return;
|
|
243
|
-
const ids = Array.from(selected);
|
|
244
|
-
try {
|
|
245
|
-
const out = await api.post<{ affected: { id: string; ok: boolean; error?: string }[] }>('/tasks/bulk', {
|
|
246
|
-
ids,
|
|
247
|
-
action,
|
|
248
|
-
params,
|
|
249
|
-
});
|
|
250
|
-
const ok = out.affected.filter((r) => r.ok).length;
|
|
251
|
-
const failed = out.affected.length - ok;
|
|
252
|
-
if (action === 'archive' || action === 'delete') {
|
|
253
|
-
setTasks((cur) => cur.filter((t) => !ids.includes(t.id)));
|
|
254
|
-
} else {
|
|
255
|
-
await reload();
|
|
175
|
+
// Re-dispatch via the existing /tasks/:id/start endpoint. The
|
|
176
|
+
// server returns 202 on accept; failures surface as 502/404.
|
|
177
|
+
const r = await api.post<{ ok: boolean; task: Task }>(
|
|
178
|
+
`/tasks/${encodeURIComponent(taskId)}/start`,
|
|
179
|
+
);
|
|
180
|
+
if (r && r.task) {
|
|
181
|
+
setTasks((cur) => cur.map((t) => (t.id === taskId ? r.task : t)));
|
|
256
182
|
}
|
|
257
|
-
toast.success(
|
|
258
|
-
setSelected(new Set());
|
|
183
|
+
toast.success('Retry dispatched.', 1200);
|
|
259
184
|
} catch (err) {
|
|
260
|
-
toast.error(`
|
|
185
|
+
toast.error(`Retry failed: ${(err as Error).message}`);
|
|
261
186
|
}
|
|
262
187
|
};
|
|
263
188
|
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const updated = await api.put<Task>(`/tasks/${encodeURIComponent(t.id)}`, { assignee: me });
|
|
268
|
-
setTasks((cur) => cur.map((x) => (x.id === t.id ? updated : x)));
|
|
269
|
-
toast.success('Assigned to me.', 1200);
|
|
270
|
-
} catch (err) {
|
|
271
|
-
toast.error(`Assign failed: ${(err as Error).message}`);
|
|
272
|
-
}
|
|
273
|
-
};
|
|
274
|
-
|
|
275
|
-
const onMarkWorkedBy = async (t: Task, agent: string, status: 'doing' | 'done' | 'idle', complete: boolean) => {
|
|
189
|
+
const submitToOdin = async (taskId: string) => {
|
|
190
|
+
const t = tasks.find((x) => x.id === taskId);
|
|
191
|
+
if (!t) return;
|
|
276
192
|
try {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
193
|
+
// The /tasks/submit endpoint takes a title + description and
|
|
194
|
+
// hands the bundle to Odin for delegation. We re-use the task's
|
|
195
|
+
// existing fields.
|
|
196
|
+
const r = await api.post<{ main: Task; subtasks: Task[] }>('/tasks/submit', {
|
|
197
|
+
title: t.title,
|
|
198
|
+
description: t.description,
|
|
199
|
+
priority: t.priority,
|
|
200
|
+
tags: t.tags,
|
|
281
201
|
});
|
|
282
|
-
|
|
283
|
-
toast.success(
|
|
202
|
+
const count = (r.subtasks || []).length;
|
|
203
|
+
toast.success(
|
|
204
|
+
count > 1
|
|
205
|
+
? `Odin split it into ${count} subtasks.`
|
|
206
|
+
: 'Sent to Odin.',
|
|
207
|
+
1500,
|
|
208
|
+
);
|
|
209
|
+
if (r.subtasks && r.subtasks.length > 0) {
|
|
210
|
+
setTasks((cur) => [r.main, ...r.subtasks, ...cur.filter((x) => x.id !== t.id)]);
|
|
211
|
+
}
|
|
212
|
+
await refreshSnapshot();
|
|
284
213
|
} catch (err) {
|
|
285
|
-
toast.error(`
|
|
214
|
+
toast.error(`Submit failed: ${(err as Error).message}`);
|
|
286
215
|
}
|
|
287
216
|
};
|
|
288
217
|
|
|
289
|
-
const toggleSelect = (id: string) => {
|
|
290
|
-
setSelected((cur) => {
|
|
291
|
-
const next = new Set(cur);
|
|
292
|
-
if (next.has(id)) next.delete(id);
|
|
293
|
-
else next.add(id);
|
|
294
|
-
return next;
|
|
295
|
-
});
|
|
296
|
-
};
|
|
297
|
-
|
|
298
|
-
const toggleSelectAll = (ids: string[]) => {
|
|
299
|
-
setSelected((cur) => {
|
|
300
|
-
const next = new Set(cur);
|
|
301
|
-
const allSelected = ids.length > 0 && ids.every((id) => next.has(id));
|
|
302
|
-
if (allSelected) {
|
|
303
|
-
ids.forEach((id) => next.delete(id));
|
|
304
|
-
return next;
|
|
305
|
-
}
|
|
306
|
-
ids.forEach((id) => next.add(id));
|
|
307
|
-
return next;
|
|
308
|
-
});
|
|
309
|
-
};
|
|
310
|
-
|
|
311
|
-
// v3.6.2 — Pipeline: open task chat session.
|
|
312
|
-
const onOpenTaskChat = (taskId: string) => {
|
|
313
|
-
setActiveTab('chat');
|
|
314
|
-
// The Chat view reads crossState.initialChatTaskId to pre-load a session.
|
|
315
|
-
// We set it via a custom event that Chat.tsx listens to on mount.
|
|
316
|
-
window.dispatchEvent(new CustomEvent('bizar:setChatTask', { detail: { taskId } }));
|
|
317
|
-
};
|
|
318
|
-
|
|
319
|
-
// v3.6.2 — Pipeline: open task artifact viewer.
|
|
320
|
-
const onOpenTaskArtifact = (artifactId: string) => {
|
|
321
|
-
openArtifactViewer(modal, artifactId);
|
|
322
|
-
};
|
|
323
|
-
|
|
324
|
-
// v3.22 — Backlog panel visibility.
|
|
325
|
-
const [showBacklog, setShowBacklog] = useState(false);
|
|
326
|
-
const backlogCount = (snapshot.tasks || []).filter((t) => t.status === 'backlog').length;
|
|
327
|
-
|
|
328
218
|
return (
|
|
329
219
|
<div className="view view-tasks">
|
|
330
220
|
<header className="view-header">
|
|
@@ -333,12 +223,12 @@ export function Tasks({ snapshot, refreshSnapshot, setActiveTab }: Props) {
|
|
|
333
223
|
<CheckSquare size={18} /> Tasks ({sorted.length})
|
|
334
224
|
</h2>
|
|
335
225
|
<p className="view-subtitle">
|
|
336
|
-
|
|
226
|
+
Add a task — title and description is all you need. Odin picks the agent and priority.
|
|
337
227
|
</p>
|
|
338
228
|
</div>
|
|
339
229
|
</header>
|
|
340
230
|
|
|
341
|
-
{/*
|
|
231
|
+
{/* Toolbar: search, filter, sort, actions */}
|
|
342
232
|
<div className="tasks-toolbar">
|
|
343
233
|
<div className="tasks-toolbar-group">
|
|
344
234
|
<div className="search-input" style={{ width: 200 }}>
|
|
@@ -366,93 +256,26 @@ export function Tasks({ snapshot, refreshSnapshot, setActiveTab }: Props) {
|
|
|
366
256
|
<div className="tasks-toolbar-divider" />
|
|
367
257
|
|
|
368
258
|
<div className="tasks-toolbar-group">
|
|
369
|
-
<span className="tasks-toolbar-label">
|
|
370
|
-
<select
|
|
371
|
-
className="select select-sm"
|
|
372
|
-
value={assigneeFilter}
|
|
373
|
-
onChange={(e) => setAssigneeFilter(e.target.value)}
|
|
374
|
-
title="Assignee"
|
|
375
|
-
>
|
|
376
|
-
<option value="">All assignees</option>
|
|
377
|
-
<option value="me">Me</option>
|
|
378
|
-
{(snapshot.agents || []).map((a) => (
|
|
379
|
-
<option key={a.name} value={a.name}>@{a.name}</option>
|
|
380
|
-
))}
|
|
381
|
-
</select>
|
|
259
|
+
<span className="tasks-toolbar-label">Priority</span>
|
|
382
260
|
<select
|
|
383
261
|
className="select select-sm"
|
|
384
262
|
value={priorityFilter}
|
|
385
263
|
onChange={(e) => setPriorityFilter(e.target.value)}
|
|
386
|
-
title="
|
|
264
|
+
title="Filter by priority"
|
|
387
265
|
>
|
|
388
|
-
<option value="">All
|
|
266
|
+
<option value="">All</option>
|
|
389
267
|
{PRIORITIES.map((p) => (
|
|
390
268
|
<option key={p} value={p}>{p}</option>
|
|
391
269
|
))}
|
|
392
270
|
</select>
|
|
393
|
-
{allTags.length > 0 && (
|
|
394
|
-
<select
|
|
395
|
-
className="select select-sm"
|
|
396
|
-
value={tagFilter}
|
|
397
|
-
onChange={(e) => setTagFilter(e.target.value)}
|
|
398
|
-
title="Tag"
|
|
399
|
-
>
|
|
400
|
-
<option value="">All tags</option>
|
|
401
|
-
{allTags.map((t) => (
|
|
402
|
-
<option key={t} value={t}>#{t}</option>
|
|
403
|
-
))}
|
|
404
|
-
</select>
|
|
405
|
-
)}
|
|
406
|
-
</div>
|
|
407
|
-
|
|
408
|
-
<div className="tasks-toolbar-divider" />
|
|
409
|
-
|
|
410
|
-
<div className="tasks-toolbar-group">
|
|
411
|
-
<span className="tasks-toolbar-label">Sort</span>
|
|
412
|
-
<select
|
|
413
|
-
className="select select-sm"
|
|
414
|
-
value={sortKey}
|
|
415
|
-
onChange={(e) => setSortKey(e.target.value as SortKey)}
|
|
416
|
-
title="Sort by"
|
|
417
|
-
>
|
|
418
|
-
{SORTS.map((s) => (
|
|
419
|
-
<option key={s.id} value={s.id}>{s.label}</option>
|
|
420
|
-
))}
|
|
421
|
-
</select>
|
|
422
271
|
</div>
|
|
423
272
|
|
|
424
273
|
<div className="tasks-toolbar-spacer" />
|
|
425
274
|
|
|
426
275
|
<div className="tasks-toolbar-group">
|
|
427
|
-
<Button
|
|
428
|
-
variant={showArchived ? 'primary' : 'ghost'}
|
|
429
|
-
size="sm"
|
|
430
|
-
onClick={() => {
|
|
431
|
-
setShowArchived((v) => !v);
|
|
432
|
-
reload({ archived: !showArchived });
|
|
433
|
-
}}
|
|
434
|
-
title={showArchived ? 'Showing archived' : 'Show archived'}
|
|
435
|
-
>
|
|
436
|
-
{showArchived ? <ArchiveRestore size={14} /> : <Archive size={14} />}
|
|
437
|
-
{showArchived ? 'Active' : 'Archived'}
|
|
438
|
-
</Button>
|
|
439
|
-
<Button variant="ghost" size="sm" onClick={() => reload()} title="Refresh" aria-label="Refresh tasks">
|
|
276
|
+
<Button variant="ghost" size="sm" onClick={reload} title="Refresh" aria-label="Refresh tasks">
|
|
440
277
|
<RefreshCw size={14} />
|
|
441
278
|
</Button>
|
|
442
|
-
<Button
|
|
443
|
-
variant="accent"
|
|
444
|
-
size="sm"
|
|
445
|
-
onClick={() => openSubmitTaskModal(modal, toast, setTasks, reload, refreshSnapshot)}
|
|
446
|
-
>
|
|
447
|
-
<Send size={14} /> Odin
|
|
448
|
-
</Button>
|
|
449
|
-
<Button
|
|
450
|
-
variant="primary"
|
|
451
|
-
size="sm"
|
|
452
|
-
onClick={() => openTaskModal(modal, toast, null, 'queued', setTasks, reload, refreshSnapshot, snapshot.agents)}
|
|
453
|
-
>
|
|
454
|
-
<Plus size={14} /> Add
|
|
455
|
-
</Button>
|
|
456
279
|
{backlogCount > 0 && (
|
|
457
280
|
<Button
|
|
458
281
|
variant={showBacklog ? 'accent' : 'ghost'}
|
|
@@ -465,42 +288,21 @@ export function Tasks({ snapshot, refreshSnapshot, setActiveTab }: Props) {
|
|
|
465
288
|
<span className="badge">{backlogCount}</span>
|
|
466
289
|
</Button>
|
|
467
290
|
)}
|
|
291
|
+
<Button
|
|
292
|
+
variant="primary"
|
|
293
|
+
size="sm"
|
|
294
|
+
onClick={() => openCreateTaskModal(modal, toast, setTasks, reload, refreshSnapshot)}
|
|
295
|
+
>
|
|
296
|
+
<Plus size={14} /> New task
|
|
297
|
+
</Button>
|
|
468
298
|
</div>
|
|
469
299
|
</div>
|
|
470
300
|
|
|
471
301
|
{showBacklog && backlogCount > 0 && (
|
|
472
|
-
<BacklogPanel
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
<div className="task-bulk-bar">
|
|
477
|
-
<span className="text-sm">
|
|
478
|
-
<CheckSquareIcon size={14} /> {selected.size} selected
|
|
479
|
-
</span>
|
|
480
|
-
<div className="task-bulk-bar-actions">
|
|
481
|
-
<Button size="sm" variant="ghost" onClick={() => onBulkAction('move', { status: 'queued' })}>
|
|
482
|
-
Queued
|
|
483
|
-
</Button>
|
|
484
|
-
<Button size="sm" variant="ghost" onClick={() => onBulkAction('move', { status: 'doing' })}>
|
|
485
|
-
Doing
|
|
486
|
-
</Button>
|
|
487
|
-
<Button size="sm" variant="ghost" onClick={() => onBulkAction('move', { status: 'done' })}>
|
|
488
|
-
Done
|
|
489
|
-
</Button>
|
|
490
|
-
<Button size="sm" variant="ghost" onClick={() => onBulkAction('priority', { priority: 'high' })}>
|
|
491
|
-
High
|
|
492
|
-
</Button>
|
|
493
|
-
<Button size="sm" variant="ghost" onClick={() => onBulkAction('archive')}>
|
|
494
|
-
<Archive size={12} /> Archive
|
|
495
|
-
</Button>
|
|
496
|
-
<Button size="sm" variant="danger" onClick={() => onBulkAction('delete')}>
|
|
497
|
-
<Trash2 size={12} /> Delete
|
|
498
|
-
</Button>
|
|
499
|
-
<Button size="sm" variant="ghost" onClick={() => setSelected(new Set())}>
|
|
500
|
-
Clear
|
|
501
|
-
</Button>
|
|
502
|
-
</div>
|
|
503
|
-
</div>
|
|
302
|
+
<BacklogPanel
|
|
303
|
+
agents={snapshot.agents || []}
|
|
304
|
+
onRefresh={refreshSnapshot}
|
|
305
|
+
/>
|
|
504
306
|
)}
|
|
505
307
|
|
|
506
308
|
{loading ? (
|
|
@@ -511,27 +313,13 @@ export function Tasks({ snapshot, refreshSnapshot, setActiveTab }: Props) {
|
|
|
511
313
|
<KanbanColumn
|
|
512
314
|
key={col.id}
|
|
513
315
|
column={col}
|
|
514
|
-
|
|
515
|
-
// surface in the DONE column so earlier-existing tasks are
|
|
516
|
-
// visible from a fresh dashboard load. The TaskCard itself
|
|
517
|
-
// shows an "Archived" badge so the user can distinguish them
|
|
518
|
-
// from completed tasks.
|
|
519
|
-
tasks={sorted.filter((t) => matchesColumn(t, col.id))}
|
|
520
|
-
selected={selected}
|
|
521
|
-
onToggleSelect={toggleSelect}
|
|
522
|
-
onToggleSelectAll={toggleSelectAll}
|
|
523
|
-
allTaskIdsInCol={sorted.filter((t) => matchesColumn(t, col.id)).map((t) => t.id)}
|
|
316
|
+
tasks={sorted.filter((t) => t.status === col.id)}
|
|
524
317
|
onMove={moveTask}
|
|
525
318
|
onDelete={deleteTask}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
onAssignMe={onAssignMe}
|
|
531
|
-
onMarkWorkedBy={onMarkWorkedBy}
|
|
532
|
-
onOpenChat={onOpenTaskChat}
|
|
533
|
-
onOpenArtifact={onOpenTaskArtifact}
|
|
534
|
-
isArchivedView={showArchived}
|
|
319
|
+
onRetry={retryTask}
|
|
320
|
+
onEdit={(t) => openEditTaskModal(modal, toast, t, setTasks, reload, refreshSnapshot)}
|
|
321
|
+
onSubmitToOdin={submitToOdin}
|
|
322
|
+
tick={tick}
|
|
535
323
|
/>
|
|
536
324
|
))}
|
|
537
325
|
</div>
|
|
@@ -540,53 +328,33 @@ export function Tasks({ snapshot, refreshSnapshot, setActiveTab }: Props) {
|
|
|
540
328
|
);
|
|
541
329
|
}
|
|
542
330
|
|
|
331
|
+
// ─── Column ────────────────────────────────────────────────────────────────
|
|
332
|
+
|
|
543
333
|
function KanbanColumn({
|
|
544
334
|
column,
|
|
545
335
|
tasks,
|
|
546
|
-
selected,
|
|
547
|
-
onToggleSelect,
|
|
548
|
-
onToggleSelectAll,
|
|
549
|
-
allTaskIdsInCol,
|
|
550
336
|
onMove,
|
|
551
337
|
onDelete,
|
|
552
|
-
|
|
553
|
-
onUnarchive,
|
|
338
|
+
onRetry,
|
|
554
339
|
onEdit,
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
onMarkWorkedBy,
|
|
558
|
-
onOpenChat,
|
|
559
|
-
onOpenArtifact,
|
|
560
|
-
isArchivedView,
|
|
340
|
+
onSubmitToOdin,
|
|
341
|
+
tick,
|
|
561
342
|
}: {
|
|
562
|
-
column:
|
|
343
|
+
column: { id: string; label: string; kind: StatusKind };
|
|
563
344
|
tasks: Task[];
|
|
564
|
-
selected: Set<string>;
|
|
565
|
-
onToggleSelect: (id: string) => void;
|
|
566
|
-
onToggleSelectAll: (ids: string[]) => void;
|
|
567
|
-
allTaskIdsInCol: string[];
|
|
568
345
|
onMove: (id: string, status: string) => void;
|
|
569
346
|
onDelete: (id: string) => void;
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
onAssignMe: (task: Task) => void;
|
|
575
|
-
onMarkWorkedBy: (task: Task, agent: string, status: 'doing' | 'done' | 'idle', complete: boolean) => void;
|
|
576
|
-
onOpenChat: (taskId: string) => void;
|
|
577
|
-
onOpenArtifact: (artifactId: string) => void;
|
|
578
|
-
isArchivedView: boolean;
|
|
347
|
+
onRetry: (id: string) => void;
|
|
348
|
+
onEdit: (t: Task) => void;
|
|
349
|
+
onSubmitToOdin: (id: string) => void;
|
|
350
|
+
tick: number;
|
|
579
351
|
}) {
|
|
580
352
|
const [dragOver, setDragOver] = useState(false);
|
|
581
|
-
const allSelected = tasks.length > 0 && allTaskIdsInCol.every((id) => selected.has(id));
|
|
582
353
|
return (
|
|
583
354
|
<div
|
|
584
355
|
className={cn('kanban-column', dragOver && 'kanban-column-drop')}
|
|
585
356
|
data-column={column.id}
|
|
586
|
-
onDragOver={(e) => {
|
|
587
|
-
e.preventDefault();
|
|
588
|
-
setDragOver(true);
|
|
589
|
-
}}
|
|
357
|
+
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
|
590
358
|
onDragLeave={() => setDragOver(false)}
|
|
591
359
|
onDrop={(e) => {
|
|
592
360
|
e.preventDefault();
|
|
@@ -596,19 +364,9 @@ function KanbanColumn({
|
|
|
596
364
|
}}
|
|
597
365
|
>
|
|
598
366
|
<div className="kanban-col-header">
|
|
599
|
-
<button
|
|
600
|
-
type="button"
|
|
601
|
-
className="icon-btn"
|
|
602
|
-
onClick={() => onToggleSelectAll(allTaskIdsInCol)}
|
|
603
|
-
title={allSelected ? 'Deselect all' : 'Select all'}
|
|
604
|
-
aria-label={allSelected ? 'Deselect all' : 'Select all'}
|
|
605
|
-
>
|
|
606
|
-
{allSelected ? <CheckSquareIcon size={12} /> : <Square size={12} />}
|
|
607
|
-
</button>
|
|
608
367
|
<CardTitle>
|
|
609
|
-
<
|
|
610
|
-
|
|
611
|
-
</StatusBadge>
|
|
368
|
+
<span className={cn('status-dot', `status-${column.kind}`)} />
|
|
369
|
+
{column.label}
|
|
612
370
|
</CardTitle>
|
|
613
371
|
<span className="kanban-col-count tabular-nums">{tasks.length}</span>
|
|
614
372
|
</div>
|
|
@@ -620,402 +378,208 @@ function KanbanColumn({
|
|
|
620
378
|
<TaskCard
|
|
621
379
|
key={t.id}
|
|
622
380
|
task={t}
|
|
623
|
-
focused={selected.has(t.id)}
|
|
624
|
-
onFocus={() => onToggleSelect(t.id)}
|
|
625
381
|
onMove={(dir) => {
|
|
626
382
|
const idx = COLUMNS.findIndex((c) => c.id === t.status);
|
|
627
383
|
const next = idx + dir;
|
|
628
|
-
if (next >= 0 && next < COLUMNS.length)
|
|
629
|
-
onMove(t.id, COLUMNS[next].id);
|
|
384
|
+
if (next >= 0 && next < COLUMNS.length) onMove(t.id, COLUMNS[next].id);
|
|
630
385
|
}}
|
|
631
386
|
onEdit={() => onEdit(t)}
|
|
632
387
|
onDelete={() => onDelete(t.id)}
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
onMarkWorkedBy={(agent, status, complete) => onMarkWorkedBy(t, agent, status, complete)}
|
|
637
|
-
onOpenChat={onOpenChat}
|
|
638
|
-
onOpenArtifact={onOpenArtifact}
|
|
639
|
-
isArchivedView={isArchivedView}
|
|
388
|
+
onRetry={() => onRetry(t.id)}
|
|
389
|
+
onSubmitToOdin={() => onSubmitToOdin(t.id)}
|
|
390
|
+
tick={tick}
|
|
640
391
|
/>
|
|
641
392
|
))
|
|
642
393
|
)}
|
|
643
394
|
</div>
|
|
644
|
-
{!isArchivedView && (
|
|
645
|
-
<footer className="kanban-col-footer">
|
|
646
|
-
<Button variant="ghost" size="sm" onClick={onAdd} className="w-full">
|
|
647
|
-
<Plus size={12} /> Add task
|
|
648
|
-
</Button>
|
|
649
|
-
</footer>
|
|
650
|
-
)}
|
|
651
395
|
</div>
|
|
652
396
|
);
|
|
653
397
|
}
|
|
654
398
|
|
|
399
|
+
// ─── Card ──────────────────────────────────────────────────────────────────
|
|
400
|
+
|
|
655
401
|
function TaskCard({
|
|
656
402
|
task,
|
|
657
|
-
focused,
|
|
658
|
-
onFocus,
|
|
659
403
|
onMove,
|
|
660
404
|
onEdit,
|
|
661
405
|
onDelete,
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
onMarkWorkedBy,
|
|
666
|
-
onOpenChat,
|
|
667
|
-
onOpenArtifact,
|
|
668
|
-
isArchivedView,
|
|
406
|
+
onRetry,
|
|
407
|
+
onSubmitToOdin,
|
|
408
|
+
tick,
|
|
669
409
|
}: {
|
|
670
410
|
task: Task;
|
|
671
|
-
focused: boolean;
|
|
672
|
-
onFocus: () => void;
|
|
673
411
|
onMove: (dir: -1 | 1) => void;
|
|
674
412
|
onEdit: () => void;
|
|
675
413
|
onDelete: () => void;
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
onMarkWorkedBy: (agent: string, status: 'doing' | 'done' | 'idle', complete: boolean) => void;
|
|
680
|
-
onOpenChat: (taskId: string) => void;
|
|
681
|
-
onOpenArtifact: (artifactId: string) => void;
|
|
682
|
-
isArchivedView: boolean;
|
|
414
|
+
onRetry: () => void;
|
|
415
|
+
onSubmitToOdin: () => void;
|
|
416
|
+
tick: number;
|
|
683
417
|
}) {
|
|
684
|
-
const
|
|
685
|
-
const modal = useModal();
|
|
686
|
-
const isTimer = task._timerStart;
|
|
687
|
-
const isWorking = (task.workedBy || task.status === 'doing') && !isArchivedView;
|
|
688
|
-
|
|
689
|
-
// v3.6.2 — Pipeline: determine if task has a chat session or artifact.
|
|
690
|
-
type TaskPipelineMeta = {
|
|
691
|
-
sessionId?: string;
|
|
692
|
-
bgInstanceId?: string;
|
|
693
|
-
status?: string;
|
|
694
|
-
artifactIds?: string[];
|
|
695
|
-
artifactId?: string;
|
|
696
|
-
};
|
|
697
|
-
const metadata = (task.metadata || {}) as TaskPipelineMeta;
|
|
698
|
-
const hasChatSession = !!(metadata.sessionId || metadata.bgInstanceId);
|
|
699
|
-
const artifactIds: string[] = metadata.artifactIds || (metadata.artifactId ? [metadata.artifactId] : []);
|
|
700
|
-
const primaryArtifactId = artifactIds[0];
|
|
418
|
+
const assignedAgent = task.workedBy || task.assignee || null;
|
|
701
419
|
return (
|
|
702
420
|
<div
|
|
703
|
-
className={cn(
|
|
704
|
-
'task-card',
|
|
705
|
-
`priority-${task.priority}`,
|
|
706
|
-
focused && 'task-card-focused',
|
|
707
|
-
isTimer ? 'task-card-timer is-working' : null,
|
|
708
|
-
isWorking && !isTimer ? 'is-working' : null,
|
|
709
|
-
)}
|
|
421
|
+
className={cn('task-card', `priority-${task.priority}`)}
|
|
710
422
|
data-task-id={task.id}
|
|
711
|
-
|
|
712
|
-
draggable={!isArchivedView}
|
|
423
|
+
draggable
|
|
713
424
|
onDragStart={(e) => {
|
|
714
425
|
e.dataTransfer.setData('text/task-id', task.id);
|
|
715
426
|
e.dataTransfer.effectAllowed = 'move';
|
|
716
427
|
}}
|
|
717
|
-
|
|
428
|
+
data-tick={tick}
|
|
718
429
|
>
|
|
719
430
|
<div className="task-card-head">
|
|
720
|
-
{/* v3.7.0 — Visible checkbox for bulk select */}
|
|
721
|
-
<button
|
|
722
|
-
type="button"
|
|
723
|
-
className={cn('task-card-checkbox', focused && 'task-card-checkbox-on')}
|
|
724
|
-
aria-label={focused ? 'Deselect task' : 'Select task'}
|
|
725
|
-
title={focused ? 'Deselect' : 'Select'}
|
|
726
|
-
onClick={(e) => {
|
|
727
|
-
e.stopPropagation();
|
|
728
|
-
onFocus();
|
|
729
|
-
}}
|
|
730
|
-
>
|
|
731
|
-
{focused ? <CheckSquareIcon size={13} /> : <Square size={13} />}
|
|
732
|
-
</button>
|
|
733
431
|
<span
|
|
734
432
|
className="priority-dot"
|
|
735
433
|
style={{ background: priorityColors[task.priority] || 'var(--info)' }}
|
|
736
434
|
/>
|
|
737
435
|
<div className="task-card-title">{task.title}</div>
|
|
738
|
-
{isTimer && <span className="task-card-timer-pill"><Clock size={10} /> running</span>}
|
|
739
|
-
{task.workedBy && task.status === 'doing' && !isTimer && (
|
|
740
|
-
<span className="task-card-timer-pill" title={`@${task.workedBy}`}>
|
|
741
|
-
<Bot size={10} /> @{task.workedBy}
|
|
742
|
-
</span>
|
|
743
|
-
)}
|
|
744
|
-
{task.recurring && (
|
|
745
|
-
<span className="task-card-timer-pill" title="Recurring">
|
|
746
|
-
<RotateCw size={10} />
|
|
747
|
-
</span>
|
|
748
|
-
)}
|
|
749
436
|
</div>
|
|
750
437
|
{task.description && (
|
|
751
438
|
<div className="task-card-desc">{task.description.slice(0, 160)}</div>
|
|
752
439
|
)}
|
|
753
440
|
<div className="task-card-badges">
|
|
754
|
-
{
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
{task.
|
|
760
|
-
{metadata.bgInstanceId ? (
|
|
441
|
+
{assignedAgent && (
|
|
442
|
+
<span className="task-card-badge" title={`Auto-assigned to @${assignedAgent}`}>
|
|
443
|
+
<Bot size={10} /> @{assignedAgent}
|
|
444
|
+
</span>
|
|
445
|
+
)}
|
|
446
|
+
{task.timeSpent ? (
|
|
761
447
|
<span className="task-card-badge">
|
|
762
|
-
<
|
|
448
|
+
<Clock size={10} /> {Math.round((task.timeSpent || 0) / 60)}m
|
|
763
449
|
</span>
|
|
764
450
|
) : null}
|
|
451
|
+
{task.tags && task.tags.length > 0 && task.tags.slice(0, 3).map((tag) => (
|
|
452
|
+
<Tag key={tag}>{tag}</Tag>
|
|
453
|
+
))}
|
|
765
454
|
</div>
|
|
766
|
-
{task.tags && task.tags.length > 0 && (
|
|
767
|
-
<div className="task-card-tags">
|
|
768
|
-
{task.tags.map((t) => (
|
|
769
|
-
<Tag key={t}>{t}</Tag>
|
|
770
|
-
))}
|
|
771
|
-
</div>
|
|
772
|
-
)}
|
|
773
455
|
<div className="task-card-footer">
|
|
774
456
|
<span className="task-card-time tabular-nums muted">
|
|
775
|
-
{formatRelative(task.
|
|
457
|
+
{formatRelative(task.createdAt)}
|
|
776
458
|
</span>
|
|
777
459
|
<div className="task-card-actions" onClick={(e) => e.stopPropagation()}>
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
className="icon-btn"
|
|
833
|
-
aria-label="Restore"
|
|
834
|
-
title="Restore"
|
|
835
|
-
onClick={onUnarchive}
|
|
836
|
-
>
|
|
837
|
-
<ArchiveRestore size={14} />
|
|
838
|
-
</button>
|
|
839
|
-
) : (
|
|
840
|
-
<button
|
|
841
|
-
type="button"
|
|
842
|
-
className="icon-btn"
|
|
843
|
-
aria-label="Archive"
|
|
844
|
-
title="Archive"
|
|
845
|
-
onClick={onArchive}
|
|
846
|
-
>
|
|
847
|
-
<Archive size={14} />
|
|
848
|
-
</button>
|
|
849
|
-
)}
|
|
850
|
-
{metadata.bgInstanceId && (
|
|
851
|
-
<button
|
|
852
|
-
type="button"
|
|
853
|
-
className="icon-btn icon-btn-danger"
|
|
854
|
-
aria-label="Kill background instance"
|
|
855
|
-
title="Kill background instance"
|
|
856
|
-
onClick={() => {
|
|
857
|
-
openKillConfirmDialog(modal, toast, metadata.bgInstanceId!, task.title, () => {
|
|
858
|
-
// Optimistically mark task as blocked after kill.
|
|
859
|
-
const event = new CustomEvent('bizar:taskKilled', {
|
|
860
|
-
detail: { taskId: task.id, bgInstanceId: metadata.bgInstanceId },
|
|
861
|
-
});
|
|
862
|
-
window.dispatchEvent(event);
|
|
863
|
-
});
|
|
864
|
-
}}
|
|
865
|
-
>
|
|
866
|
-
<Activity size={14} />
|
|
867
|
-
</button>
|
|
868
|
-
)}
|
|
869
|
-
<button
|
|
870
|
-
type="button"
|
|
871
|
-
className="icon-btn icon-btn-danger"
|
|
872
|
-
aria-label="Delete"
|
|
873
|
-
title="Delete"
|
|
874
|
-
onClick={onDelete}
|
|
875
|
-
>
|
|
876
|
-
<Trash2 size={14} />
|
|
877
|
-
</button>
|
|
878
|
-
<button
|
|
879
|
-
type="button"
|
|
880
|
-
className="icon-btn"
|
|
881
|
-
aria-label="Move right"
|
|
882
|
-
title="Move right"
|
|
883
|
-
onClick={() => onMove(1)}
|
|
884
|
-
>
|
|
885
|
-
<ChevronRight size={14} />
|
|
886
|
-
</button>
|
|
887
|
-
</>
|
|
888
|
-
)}
|
|
889
|
-
{isArchivedView && (
|
|
890
|
-
<>
|
|
891
|
-
<button
|
|
892
|
-
type="button"
|
|
893
|
-
className="icon-btn"
|
|
894
|
-
aria-label="Restore"
|
|
895
|
-
title="Restore"
|
|
896
|
-
onClick={onUnarchive}
|
|
897
|
-
>
|
|
898
|
-
<ArchiveRestore size={14} />
|
|
899
|
-
</button>
|
|
900
|
-
<button
|
|
901
|
-
type="button"
|
|
902
|
-
className="icon-btn icon-btn-danger"
|
|
903
|
-
aria-label="Delete"
|
|
904
|
-
title="Delete"
|
|
905
|
-
onClick={onDelete}
|
|
906
|
-
>
|
|
907
|
-
<Trash2 size={14} />
|
|
908
|
-
</button>
|
|
909
|
-
</>
|
|
910
|
-
)}
|
|
460
|
+
<button
|
|
461
|
+
type="button"
|
|
462
|
+
className="icon-btn"
|
|
463
|
+
aria-label="Move left"
|
|
464
|
+
title="Move left"
|
|
465
|
+
onClick={() => onMove(-1)}
|
|
466
|
+
>
|
|
467
|
+
<ChevronLeft size={14} />
|
|
468
|
+
</button>
|
|
469
|
+
<button
|
|
470
|
+
type="button"
|
|
471
|
+
className="icon-btn"
|
|
472
|
+
aria-label="Edit"
|
|
473
|
+
title="Edit"
|
|
474
|
+
onClick={onEdit}
|
|
475
|
+
>
|
|
476
|
+
<Edit2 size={14} />
|
|
477
|
+
</button>
|
|
478
|
+
<button
|
|
479
|
+
type="button"
|
|
480
|
+
className="icon-btn"
|
|
481
|
+
aria-label="Submit to Odin"
|
|
482
|
+
title="Re-delegate to Odin"
|
|
483
|
+
onClick={onSubmitToOdin}
|
|
484
|
+
>
|
|
485
|
+
<Send size={14} />
|
|
486
|
+
</button>
|
|
487
|
+
<button
|
|
488
|
+
type="button"
|
|
489
|
+
className="icon-btn"
|
|
490
|
+
aria-label="Retry"
|
|
491
|
+
title="Retry"
|
|
492
|
+
onClick={onRetry}
|
|
493
|
+
>
|
|
494
|
+
<RotateCw size={14} />
|
|
495
|
+
</button>
|
|
496
|
+
<button
|
|
497
|
+
type="button"
|
|
498
|
+
className="icon-btn icon-btn-danger"
|
|
499
|
+
aria-label="Delete"
|
|
500
|
+
title="Delete"
|
|
501
|
+
onClick={onDelete}
|
|
502
|
+
>
|
|
503
|
+
<Trash2 size={14} />
|
|
504
|
+
</button>
|
|
505
|
+
<button
|
|
506
|
+
type="button"
|
|
507
|
+
className="icon-btn"
|
|
508
|
+
aria-label="Move right"
|
|
509
|
+
title="Move right"
|
|
510
|
+
onClick={() => onMove(1)}
|
|
511
|
+
>
|
|
512
|
+
<ChevronRight size={14} />
|
|
513
|
+
</button>
|
|
911
514
|
</div>
|
|
912
515
|
</div>
|
|
913
516
|
</div>
|
|
914
517
|
);
|
|
915
518
|
}
|
|
916
519
|
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
return `${m}m`;
|
|
923
|
-
}
|
|
520
|
+
// ─── Create / Edit modal ───────────────────────────────────────────────────
|
|
521
|
+
//
|
|
522
|
+
// Simplified: title, description, priority hint, optional tags. NO
|
|
523
|
+
// agent dropdown. The store will default `assignee` and `workedBy` to
|
|
524
|
+
// null; the orchestrator sets them when it picks the task up.
|
|
924
525
|
|
|
925
|
-
|
|
526
|
+
function openCreateTaskModal(
|
|
926
527
|
modal: ReturnType<typeof useModal>,
|
|
927
528
|
toast: ReturnType<typeof useToast>,
|
|
928
|
-
task: Task | null,
|
|
929
|
-
initialStatus: string,
|
|
930
529
|
setTasks: (updater: (cur: Task[]) => Task[]) => void,
|
|
931
530
|
reload: () => Promise<void>,
|
|
932
531
|
refreshSnapshot: () => Promise<void>,
|
|
933
|
-
agents: Agent[] = [],
|
|
934
532
|
) {
|
|
935
533
|
let titleEl: HTMLInputElement | null = null;
|
|
936
534
|
let descEl: HTMLTextAreaElement | null = null;
|
|
937
535
|
let tagsEl: HTMLInputElement | null = null;
|
|
938
|
-
let priorityEl:
|
|
939
|
-
let statusEl: HTMLSelectElement | null = null;
|
|
940
|
-
let assigneeEl: HTMLInputElement | null = null;
|
|
941
|
-
let recurEl: HTMLSelectElement | null = null;
|
|
942
|
-
let cronEl: HTMLInputElement | null = null;
|
|
943
|
-
let dueEl: HTMLInputElement | null = null;
|
|
536
|
+
let priorityEl: HTMLSelectElement | null = null;
|
|
944
537
|
|
|
945
|
-
const submit = async () => {
|
|
946
|
-
|
|
538
|
+
const submit = async (e?: React.SyntheticEvent) => {
|
|
539
|
+
if (e && typeof e.preventDefault === 'function') e.preventDefault();
|
|
540
|
+
const title = (titleEl?.value || '').trim();
|
|
541
|
+
const description = (descEl?.value || '').trim();
|
|
947
542
|
if (!title) {
|
|
948
543
|
toast.warning('Title is required.');
|
|
949
544
|
titleEl?.focus();
|
|
950
545
|
return;
|
|
951
546
|
}
|
|
952
|
-
const
|
|
953
|
-
const
|
|
954
|
-
(
|
|
955
|
-
|
|
956
|
-
)
|
|
957
|
-
const tagsRaw = tagsEl?.value.trim() || '';
|
|
958
|
-
const tags = tagsRaw
|
|
959
|
-
? tagsRaw.split(',').map((t) => t.trim()).filter(Boolean)
|
|
960
|
-
: [];
|
|
961
|
-
const status = statusEl?.value || initialStatus;
|
|
962
|
-
const assignee = (assigneeEl?.value || '').trim() || null;
|
|
963
|
-
const recurFreq = recurEl?.value || '';
|
|
964
|
-
const cron = cronEl?.value.trim() || '';
|
|
965
|
-
const recurring = recurFreq === 'custom' && cron
|
|
966
|
-
? { freq: 'custom', cron }
|
|
967
|
-
: recurFreq && recurFreq !== 'none'
|
|
968
|
-
? { freq: recurFreq }
|
|
969
|
-
: null;
|
|
970
|
-
const dueDate = dueEl?.value || null;
|
|
547
|
+
const priority = (priorityEl?.value || 'normal') as Priority;
|
|
548
|
+
const tags = (tagsEl?.value || '')
|
|
549
|
+
.split(',')
|
|
550
|
+
.map((s) => s.trim())
|
|
551
|
+
.filter(Boolean);
|
|
971
552
|
|
|
972
553
|
try {
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
});
|
|
984
|
-
// v3.3.1 — close modal first, bump safe window, then state.
|
|
985
|
-
modal.close();
|
|
986
|
-
if (typeof window !== 'undefined') {
|
|
987
|
-
window.dispatchEvent(new MouseEvent('mousedown'));
|
|
988
|
-
}
|
|
989
|
-
setTasks((cur) => cur.map((x) => (x.id === task.id ? updated : x)));
|
|
990
|
-
toast.success('Task updated.', 1500);
|
|
991
|
-
} else {
|
|
992
|
-
const created = await api.post<Task>('/tasks', {
|
|
993
|
-
title,
|
|
994
|
-
description,
|
|
995
|
-
status,
|
|
996
|
-
tags,
|
|
997
|
-
priority,
|
|
998
|
-
assignee,
|
|
999
|
-
recurring,
|
|
1000
|
-
dueDate,
|
|
1001
|
-
});
|
|
1002
|
-
// v3.3.1 — close modal first, bump safe window, then state.
|
|
1003
|
-
modal.close();
|
|
1004
|
-
if (typeof window !== 'undefined') {
|
|
1005
|
-
window.dispatchEvent(new MouseEvent('mousedown'));
|
|
1006
|
-
}
|
|
1007
|
-
setTasks((cur) => [created, ...cur]);
|
|
1008
|
-
toast.success('Task created.', 1500);
|
|
554
|
+
const created = await api.post<Task>('/tasks', {
|
|
555
|
+
title,
|
|
556
|
+
description,
|
|
557
|
+
priority,
|
|
558
|
+
tags,
|
|
559
|
+
// No assignee / agent — Odin decides.
|
|
560
|
+
});
|
|
561
|
+
modal.close();
|
|
562
|
+
if (typeof window !== 'undefined') {
|
|
563
|
+
window.dispatchEvent(new MouseEvent('mousedown'));
|
|
1009
564
|
}
|
|
565
|
+
setTasks((cur) => [created, ...cur]);
|
|
566
|
+
toast.success('Task created.', 1200);
|
|
1010
567
|
await refreshSnapshot();
|
|
1011
568
|
} catch (err) {
|
|
1012
|
-
toast.error(`
|
|
569
|
+
toast.error(`Create failed: ${(err as Error).message}`);
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
const onTitleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
574
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
575
|
+
e.preventDefault();
|
|
576
|
+
submit(e);
|
|
1013
577
|
}
|
|
1014
578
|
};
|
|
1015
579
|
|
|
1016
580
|
modal.open({
|
|
1017
|
-
title:
|
|
1018
|
-
width:
|
|
581
|
+
title: 'New task',
|
|
582
|
+
width: 520,
|
|
1019
583
|
children: (
|
|
1020
584
|
<div className="task-form">
|
|
1021
585
|
<label className="field-label" htmlFor="task-title">Title *</label>
|
|
@@ -1026,520 +590,185 @@ export function openTaskModal(
|
|
|
1026
590
|
type="text"
|
|
1027
591
|
maxLength={200}
|
|
1028
592
|
placeholder="What needs to be done?"
|
|
1029
|
-
defaultValue={task?.title || ''}
|
|
1030
593
|
autoFocus
|
|
594
|
+
onKeyDown={onTitleKeyDown}
|
|
1031
595
|
/>
|
|
1032
|
-
<label className="field-label" htmlFor="task-desc">Description
|
|
1033
|
-
<
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
596
|
+
<label className="field-label" htmlFor="task-desc">Description</label>
|
|
597
|
+
<div style={{ display: 'flex', gap: 6, alignItems: 'flex-start' }}>
|
|
598
|
+
<textarea
|
|
599
|
+
ref={(el) => { descEl = el; }}
|
|
600
|
+
id="task-desc"
|
|
601
|
+
className="textarea"
|
|
602
|
+
rows={4}
|
|
603
|
+
placeholder="Add any context Odin might need (markdown ok)…"
|
|
604
|
+
style={{ flex: 1 }}
|
|
605
|
+
/>
|
|
606
|
+
<button
|
|
607
|
+
type="button"
|
|
608
|
+
className="btn btn-ghost btn-icon"
|
|
609
|
+
title="Enhance with AI"
|
|
610
|
+
onClick={async () => {
|
|
611
|
+
if (!descEl?.value?.trim()) return;
|
|
612
|
+
const enhanced = await enhancePrompt(descEl.value);
|
|
613
|
+
if (enhanced !== descEl.value) descEl.value = enhanced;
|
|
614
|
+
}}
|
|
615
|
+
>
|
|
616
|
+
✨
|
|
617
|
+
</button>
|
|
618
|
+
</div>
|
|
1041
619
|
<div className="task-form-row">
|
|
1042
|
-
<
|
|
1043
|
-
|
|
620
|
+
<label htmlFor="task-priority" className="task-form-field">
|
|
621
|
+
Priority hint
|
|
1044
622
|
<select
|
|
1045
|
-
ref={(el) => {
|
|
1046
|
-
id="task-
|
|
623
|
+
ref={(el) => { priorityEl = el; }}
|
|
624
|
+
id="task-priority"
|
|
1047
625
|
className="select"
|
|
1048
|
-
defaultValue=
|
|
626
|
+
defaultValue="normal"
|
|
1049
627
|
>
|
|
1050
|
-
{COLUMNS.map((c) => (
|
|
1051
|
-
<option key={c.id} value={c.id}>{c.label}</option>
|
|
1052
|
-
))}
|
|
1053
|
-
</select>
|
|
1054
|
-
</div>
|
|
1055
|
-
<div className="task-form-field">
|
|
1056
|
-
<label className="field-label">Priority</label>
|
|
1057
|
-
<div className="radio-row" ref={(el) => { priorityEl = el; }}>
|
|
1058
628
|
{PRIORITIES.map((p) => (
|
|
1059
|
-
<
|
|
1060
|
-
<input
|
|
1061
|
-
type="radio"
|
|
1062
|
-
name="task-priority"
|
|
1063
|
-
value={p}
|
|
1064
|
-
defaultChecked={(task?.priority || 'normal') === p}
|
|
1065
|
-
/>
|
|
1066
|
-
<span className="capitalize">{p}</span>
|
|
1067
|
-
</label>
|
|
1068
|
-
))}
|
|
1069
|
-
</div>
|
|
1070
|
-
</div>
|
|
1071
|
-
</div>
|
|
1072
|
-
<div className="task-form-row">
|
|
1073
|
-
<div className="task-form-field">
|
|
1074
|
-
<label className="field-label" htmlFor="task-assignee">Assignee</label>
|
|
1075
|
-
<input
|
|
1076
|
-
ref={(el) => { assigneeEl = el; }}
|
|
1077
|
-
id="task-assignee"
|
|
1078
|
-
className="input"
|
|
1079
|
-
type="text"
|
|
1080
|
-
placeholder="odin, thor, me, …"
|
|
1081
|
-
list="task-assignee-suggestions"
|
|
1082
|
-
defaultValue={task?.assignee || ''}
|
|
1083
|
-
/>
|
|
1084
|
-
<datalist id="task-assignee-suggestions">
|
|
1085
|
-
{agents.map((a) => (
|
|
1086
|
-
<option key={a.name} value={a.name}>@{a.name}</option>
|
|
629
|
+
<option key={p} value={p}>{p}</option>
|
|
1087
630
|
))}
|
|
1088
|
-
</datalist>
|
|
1089
|
-
</div>
|
|
1090
|
-
<div className="task-form-field">
|
|
1091
|
-
<label className="field-label" htmlFor="task-due">Due date</label>
|
|
1092
|
-
<input
|
|
1093
|
-
ref={(el) => { dueEl = el; }}
|
|
1094
|
-
id="task-due"
|
|
1095
|
-
className="input"
|
|
1096
|
-
type="date"
|
|
1097
|
-
defaultValue={task?.dueDate?.slice(0, 10) || ''}
|
|
1098
|
-
/>
|
|
1099
|
-
</div>
|
|
1100
|
-
</div>
|
|
1101
|
-
<label className="field-label" htmlFor="task-tags">Tags</label>
|
|
1102
|
-
<input
|
|
1103
|
-
ref={(el) => { tagsEl = el; }}
|
|
1104
|
-
id="task-tags"
|
|
1105
|
-
className="input"
|
|
1106
|
-
type="text"
|
|
1107
|
-
placeholder="comma-separated, e.g. bug, frontend"
|
|
1108
|
-
defaultValue={(task?.tags || []).join(', ')}
|
|
1109
|
-
/>
|
|
1110
|
-
<div className="task-form-row">
|
|
1111
|
-
<div className="task-form-field">
|
|
1112
|
-
<label className="field-label" htmlFor="task-recur">Recurring</label>
|
|
1113
|
-
<select
|
|
1114
|
-
ref={(el) => { recurEl = el; }}
|
|
1115
|
-
id="task-recur"
|
|
1116
|
-
className="select"
|
|
1117
|
-
defaultValue={task?.recurring?.freq || 'none'}
|
|
1118
|
-
>
|
|
1119
|
-
<option value="none">None</option>
|
|
1120
|
-
<option value="daily">Daily</option>
|
|
1121
|
-
<option value="weekly">Weekly</option>
|
|
1122
|
-
<option value="monthly">Monthly</option>
|
|
1123
|
-
<option value="custom">Custom (cron)</option>
|
|
1124
631
|
</select>
|
|
1125
|
-
</
|
|
1126
|
-
<
|
|
1127
|
-
<
|
|
632
|
+
</label>
|
|
633
|
+
<label htmlFor="task-tags" className="task-form-field" style={{ flex: 2 }}>
|
|
634
|
+
Tags <span className="field-hint">(comma-separated)</span>
|
|
1128
635
|
<input
|
|
1129
|
-
ref={(el) => {
|
|
1130
|
-
id="task-
|
|
636
|
+
ref={(el) => { tagsEl = el; }}
|
|
637
|
+
id="task-tags"
|
|
1131
638
|
className="input"
|
|
1132
639
|
type="text"
|
|
1133
|
-
placeholder="
|
|
1134
|
-
defaultValue={task?.recurring?.cron || ''}
|
|
640
|
+
placeholder="bug, frontend, urgent"
|
|
1135
641
|
/>
|
|
1136
|
-
</
|
|
642
|
+
</label>
|
|
1137
643
|
</div>
|
|
644
|
+
<p className="muted text-sm" style={{ marginTop: 8 }}>
|
|
645
|
+
The agent and final priority are decided by Odin after you submit.
|
|
646
|
+
</p>
|
|
1138
647
|
</div>
|
|
1139
648
|
),
|
|
1140
649
|
footer: (
|
|
1141
650
|
<div className="modal-footer-actions">
|
|
1142
651
|
<Button variant="ghost" onClick={() => modal.close()}>Cancel</Button>
|
|
1143
|
-
<Button variant="primary" onClick={
|
|
1144
|
-
|
|
1145
|
-
),
|
|
1146
|
-
});
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
function openTaskDetail(
|
|
1150
|
-
modal: ReturnType<typeof useModal>,
|
|
1151
|
-
toast: ReturnType<typeof useToast>,
|
|
1152
|
-
task: Task,
|
|
1153
|
-
setTasks: (updater: (cur: Task[]) => Task[]) => void,
|
|
1154
|
-
reload: () => Promise<void>,
|
|
1155
|
-
refreshSnapshot: () => Promise<void>,
|
|
1156
|
-
agents: Agent[] = [],
|
|
1157
|
-
tick: number = 0,
|
|
1158
|
-
) {
|
|
1159
|
-
let commentEl: HTMLTextAreaElement | null = null;
|
|
1160
|
-
let depEl: HTMLInputElement | null = null;
|
|
1161
|
-
let agentEl: HTMLSelectElement | null = null;
|
|
1162
|
-
|
|
1163
|
-
const refresh = async () => {
|
|
1164
|
-
try {
|
|
1165
|
-
const r = await api.get<Task[]>(`/tasks?archived=${task.archived ? 'true' : 'false'}`);
|
|
1166
|
-
const found = r.find((t) => t.id === task.id);
|
|
1167
|
-
if (found) {
|
|
1168
|
-
setTasks((cur) => cur.map((x) => (x.id === found.id ? found : x)));
|
|
1169
|
-
}
|
|
1170
|
-
} catch { /* ignore */ }
|
|
1171
|
-
};
|
|
1172
|
-
|
|
1173
|
-
const onComment = async () => {
|
|
1174
|
-
const text = (commentEl?.value || '').trim();
|
|
1175
|
-
if (!text) return;
|
|
1176
|
-
try {
|
|
1177
|
-
const updated = await api.post<Task>(`/tasks/${encodeURIComponent(task.id)}/comments`, { text });
|
|
1178
|
-
setTasks((cur) => cur.map((x) => (x.id === task.id ? updated : x)));
|
|
1179
|
-
if (commentEl) commentEl.value = '';
|
|
1180
|
-
toast.success('Comment added.', 1200);
|
|
1181
|
-
} catch (err) {
|
|
1182
|
-
toast.error(`Comment failed: ${(err as Error).message}`);
|
|
1183
|
-
}
|
|
1184
|
-
};
|
|
1185
|
-
|
|
1186
|
-
const onStartTimer = async () => {
|
|
1187
|
-
try {
|
|
1188
|
-
const updated = await api.post<Task>(`/tasks/${encodeURIComponent(task.id)}/timer/start`);
|
|
1189
|
-
setTasks((cur) => cur.map((x) => (x.id === task.id ? updated : x)));
|
|
1190
|
-
toast.success('Timer started.', 1200);
|
|
1191
|
-
} catch (err) {
|
|
1192
|
-
toast.error(`Start failed: ${(err as Error).message}`);
|
|
1193
|
-
}
|
|
1194
|
-
};
|
|
1195
|
-
|
|
1196
|
-
const onStopTimer = async () => {
|
|
1197
|
-
try {
|
|
1198
|
-
const updated = await api.post<Task>(`/tasks/${encodeURIComponent(task.id)}/timer/stop`);
|
|
1199
|
-
setTasks((cur) => cur.map((x) => (x.id === task.id ? updated : x)));
|
|
1200
|
-
toast.success('Timer stopped.', 1200);
|
|
1201
|
-
} catch (err) {
|
|
1202
|
-
toast.error(`Stop failed: ${(err as Error).message}`);
|
|
1203
|
-
}
|
|
1204
|
-
};
|
|
1205
|
-
|
|
1206
|
-
const onAddDep = async () => {
|
|
1207
|
-
const id = (depEl?.value || '').trim();
|
|
1208
|
-
if (!id) return;
|
|
1209
|
-
const next = [...(task.dependencies || []), id];
|
|
1210
|
-
try {
|
|
1211
|
-
const updated = await api.put<Task>(`/tasks/${encodeURIComponent(task.id)}`, { dependencies: next });
|
|
1212
|
-
setTasks((cur) => cur.map((x) => (x.id === task.id ? updated : x)));
|
|
1213
|
-
if (depEl) depEl.value = '';
|
|
1214
|
-
toast.success('Dependency added.', 1200);
|
|
1215
|
-
} catch (err) {
|
|
1216
|
-
toast.error(`Failed: ${(err as Error).message}`);
|
|
1217
|
-
}
|
|
1218
|
-
};
|
|
1219
|
-
|
|
1220
|
-
const onMarkWorkedBy = async (status: 'doing' | 'done' | 'idle', complete: boolean) => {
|
|
1221
|
-
const agent = agentEl?.value || '';
|
|
1222
|
-
if (!agent) {
|
|
1223
|
-
toast.warning('Pick an agent first.');
|
|
1224
|
-
return;
|
|
1225
|
-
}
|
|
1226
|
-
try {
|
|
1227
|
-
const updated = await api.post<Task>(`/tasks/${encodeURIComponent(task.id)}/work`, {
|
|
1228
|
-
agent,
|
|
1229
|
-
status,
|
|
1230
|
-
complete,
|
|
1231
|
-
});
|
|
1232
|
-
setTasks((cur) => cur.map((x) => (x.id === task.id ? updated : x)));
|
|
1233
|
-
toast.success(`${status} by @${agent}.`, 1200);
|
|
1234
|
-
} catch (err) {
|
|
1235
|
-
toast.error(`Failed: ${(err as Error).message}`);
|
|
1236
|
-
}
|
|
1237
|
-
};
|
|
1238
|
-
|
|
1239
|
-
const timerSeconds = task._timerStart
|
|
1240
|
-
? Math.floor((Date.now() - task._timerStart) / 1000) + (task.timeSpent || 0)
|
|
1241
|
-
: (task.timeSpent || 0);
|
|
1242
|
-
|
|
1243
|
-
modal.open({
|
|
1244
|
-
title: `Task — ${task.title}`,
|
|
1245
|
-
width: 640,
|
|
1246
|
-
children: (
|
|
1247
|
-
<div className="task-detail">
|
|
1248
|
-
<div className="task-detail-meta">
|
|
1249
|
-
<span><strong>Status:</strong> {task.status}</span>
|
|
1250
|
-
<span><strong>Priority:</strong> {task.priority}</span>
|
|
1251
|
-
{task.assignee && <span><strong>Assignee:</strong> @{task.assignee}</span>}
|
|
1252
|
-
{task.workedBy && <span><strong>Working:</strong> <span className="is-working" style={{ display: 'inline-block', padding: '0 6px' }}>@{task.workedBy}</span></span>}
|
|
1253
|
-
{task.dueDate && <span><strong>Due:</strong> {task.dueDate.slice(0, 10)}</span>}
|
|
1254
|
-
<span><strong>Time:</strong> {formatDuration(timerSeconds)}</span>
|
|
1255
|
-
</div>
|
|
1256
|
-
{task.description && (
|
|
1257
|
-
<Card>
|
|
1258
|
-
<CardTitle>Description</CardTitle>
|
|
1259
|
-
<div className="task-detail-desc">{task.description}</div>
|
|
1260
|
-
</Card>
|
|
1261
|
-
)}
|
|
1262
|
-
<Card>
|
|
1263
|
-
<CardTitle><Bot size={14} /> Mark as worked on by</CardTitle>
|
|
1264
|
-
<div className="task-detail-row">
|
|
1265
|
-
<select ref={(el) => { agentEl = el; }} className="select" defaultValue={task.workedBy || ''}>
|
|
1266
|
-
<option value="">(pick an agent)</option>
|
|
1267
|
-
{agents.map((a) => (
|
|
1268
|
-
<option key={a.name} value={a.name}>@{a.name}</option>
|
|
1269
|
-
))}
|
|
1270
|
-
</select>
|
|
1271
|
-
<Button variant="primary" size="sm" onClick={() => onMarkWorkedBy('doing', false)}>
|
|
1272
|
-
<PlayCircle size={12} /> Start working
|
|
1273
|
-
</Button>
|
|
1274
|
-
<Button variant="success" size="sm" onClick={() => onMarkWorkedBy('done', true)}>
|
|
1275
|
-
<CheckSquare size={12} /> Complete
|
|
1276
|
-
</Button>
|
|
1277
|
-
<Button variant="ghost" size="sm" onClick={() => onMarkWorkedBy('idle', false)}>
|
|
1278
|
-
<PauseCircle size={12} /> Stop
|
|
1279
|
-
</Button>
|
|
1280
|
-
</div>
|
|
1281
|
-
</Card>
|
|
1282
|
-
<Card>
|
|
1283
|
-
<CardTitle><Clock size={14} /> Time tracking</CardTitle>
|
|
1284
|
-
<div className="task-detail-row">
|
|
1285
|
-
{!task._timerStart ? (
|
|
1286
|
-
<Button variant="secondary" size="sm" onClick={onStartTimer}>
|
|
1287
|
-
<PlayCircle size={12} /> Start
|
|
1288
|
-
</Button>
|
|
1289
|
-
) : (
|
|
1290
|
-
<Button variant="secondary" size="sm" onClick={onStopTimer} className="is-working">
|
|
1291
|
-
<PauseCircle size={12} /> Stop
|
|
1292
|
-
</Button>
|
|
1293
|
-
)}
|
|
1294
|
-
<span className={cn('muted', task._timerStart ? 'is-working' : null)}>
|
|
1295
|
-
{formatDuration(timerSeconds)}
|
|
1296
|
-
{task._timerStart && ' (running)'}
|
|
1297
|
-
</span>
|
|
1298
|
-
</div>
|
|
1299
|
-
</Card>
|
|
1300
|
-
<Card>
|
|
1301
|
-
<CardTitle><Link2 size={14} /> Dependencies</CardTitle>
|
|
1302
|
-
<div className="task-detail-row">
|
|
1303
|
-
<input
|
|
1304
|
-
ref={(el) => { depEl = el; }}
|
|
1305
|
-
className="input"
|
|
1306
|
-
type="text"
|
|
1307
|
-
placeholder="task id (e.g. tsk_abc12345)"
|
|
1308
|
-
/>
|
|
1309
|
-
<Button variant="secondary" size="sm" onClick={onAddDep}>Add</Button>
|
|
1310
|
-
</div>
|
|
1311
|
-
<ul>
|
|
1312
|
-
{(task.dependencies || []).map((d) => (
|
|
1313
|
-
<li key={d}><code>{d}</code></li>
|
|
1314
|
-
))}
|
|
1315
|
-
{(task.dependencies || []).length === 0 && <li className="muted">No dependencies.</li>}
|
|
1316
|
-
</ul>
|
|
1317
|
-
</Card>
|
|
1318
|
-
{task.recurring && (
|
|
1319
|
-
<Card>
|
|
1320
|
-
<CardTitle><Calendar size={14} /> Recurring</CardTitle>
|
|
1321
|
-
<div className="muted">Frequency: <code>{task.recurring.freq || 'custom'}</code> {task.recurring.cron && <code>{task.recurring.cron}</code>}</div>
|
|
1322
|
-
{task.recurring.lastGenerated && (
|
|
1323
|
-
<div className="muted text-sm">Last spawned: {formatRelative(task.recurring.lastGenerated)}</div>
|
|
1324
|
-
)}
|
|
1325
|
-
</Card>
|
|
1326
|
-
)}
|
|
1327
|
-
<Card>
|
|
1328
|
-
<CardTitle><MessageSquare size={14} /> Comments</CardTitle>
|
|
1329
|
-
<textarea
|
|
1330
|
-
ref={(el) => { commentEl = el; }}
|
|
1331
|
-
className="textarea"
|
|
1332
|
-
rows={3}
|
|
1333
|
-
placeholder="Add a comment…"
|
|
1334
|
-
/>
|
|
1335
|
-
<Button variant="primary" size="sm" onClick={onComment}>Post</Button>
|
|
1336
|
-
<ul className="task-comments">
|
|
1337
|
-
{(task.comments || []).map((c) => (
|
|
1338
|
-
<li key={c.id}>
|
|
1339
|
-
<div className="muted tabular-nums">{formatRelative(c.createdAt)}</div>
|
|
1340
|
-
<div>{c.text}</div>
|
|
1341
|
-
</li>
|
|
1342
|
-
))}
|
|
1343
|
-
{(task.comments || []).length === 0 && <li className="muted">No comments.</li>}
|
|
1344
|
-
</ul>
|
|
1345
|
-
</Card>
|
|
1346
|
-
<Card>
|
|
1347
|
-
<CardTitle><Activity size={14} /> Activity</CardTitle>
|
|
1348
|
-
<ul className="task-activity">
|
|
1349
|
-
{(task.activity || []).slice().reverse().slice(0, 20).map((a) => (
|
|
1350
|
-
<li key={a.id}>
|
|
1351
|
-
<span className="muted tabular-nums">{formatRelative(a.ts)}</span>{' '}
|
|
1352
|
-
<span className="tag">{a.type}</span>
|
|
1353
|
-
{a.data && typeof a.data === 'object' ? (
|
|
1354
|
-
<code className="muted"> {JSON.stringify(a.data)}</code>
|
|
1355
|
-
) : null}
|
|
1356
|
-
</li>
|
|
1357
|
-
))}
|
|
1358
|
-
{(task.activity || []).length === 0 && <li className="muted">No activity yet.</li>}
|
|
1359
|
-
</ul>
|
|
1360
|
-
</Card>
|
|
1361
|
-
</div>
|
|
1362
|
-
),
|
|
1363
|
-
footer: (
|
|
1364
|
-
<div className="modal-footer-actions">
|
|
1365
|
-
<Button variant="ghost" onClick={() => modal.close()}>Close</Button>
|
|
1366
|
-
<Button variant="primary" onClick={() => { modal.close(); openTaskModal(modal, toast, task, task.status, setTasks, reload, refreshSnapshot, agents); }}>
|
|
1367
|
-
<Sparkles size={12} /> Edit basics
|
|
652
|
+
<Button variant="primary" type="button" onClick={(e) => submit(e)}>
|
|
653
|
+
<Plus size={14} /> Create task
|
|
1368
654
|
</Button>
|
|
1369
655
|
</div>
|
|
1370
656
|
),
|
|
1371
657
|
});
|
|
1372
658
|
}
|
|
1373
659
|
|
|
1374
|
-
|
|
1375
|
-
// assigns each to the best-fit agent. Falls back to a regular task
|
|
1376
|
-
// create if the delegator route is unavailable (older dashboards).
|
|
1377
|
-
function openSubmitTaskModal(
|
|
660
|
+
function openEditTaskModal(
|
|
1378
661
|
modal: ReturnType<typeof useModal>,
|
|
1379
662
|
toast: ReturnType<typeof useToast>,
|
|
663
|
+
task: Task,
|
|
1380
664
|
setTasks: (updater: (cur: Task[]) => Task[]) => void,
|
|
1381
665
|
reload: () => Promise<void>,
|
|
1382
666
|
refreshSnapshot: () => Promise<void>,
|
|
1383
667
|
) {
|
|
1384
668
|
let titleEl: HTMLInputElement | null = null;
|
|
1385
669
|
let descEl: HTMLTextAreaElement | null = null;
|
|
1386
|
-
let priorityEl: HTMLSelectElement | null = null;
|
|
1387
670
|
let tagsEl: HTMLInputElement | null = null;
|
|
671
|
+
let priorityEl: HTMLSelectElement | null = null;
|
|
1388
672
|
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
// trigger a sibling button or, more importantly, allow a
|
|
1393
|
-
// subsequent digit-key event to fire setActiveTab("overview")
|
|
1394
|
-
// because the just-closed modal had removed the user's focused
|
|
1395
|
-
// form control.
|
|
1396
|
-
const onSubmit = async (e?: React.SyntheticEvent) => {
|
|
1397
|
-
if (e && typeof e.preventDefault === 'function') e.preventDefault();
|
|
1398
|
-
if (e && typeof e.stopPropagation === 'function') e.stopPropagation();
|
|
1399
|
-
const description = descEl?.value || '';
|
|
1400
|
-
let title = (titleEl?.value || '').trim();
|
|
1401
|
-
// v3.7.1 — Title is now optional. Auto-generate from the description
|
|
1402
|
-
// when the user left it blank. Reject only if BOTH fields are empty.
|
|
1403
|
-
if (!title && description.trim()) {
|
|
1404
|
-
title = autoTitleFromContent(description);
|
|
1405
|
-
if (titleEl) titleEl.value = title;
|
|
1406
|
-
}
|
|
673
|
+
const submit = async () => {
|
|
674
|
+
const title = (titleEl?.value || '').trim();
|
|
675
|
+
const description = (descEl?.value || '').trim();
|
|
1407
676
|
if (!title) {
|
|
1408
|
-
toast.warning('
|
|
677
|
+
toast.warning('Title is required.');
|
|
678
|
+
titleEl?.focus();
|
|
1409
679
|
return;
|
|
1410
680
|
}
|
|
1411
|
-
const priority = priorityEl?.value || 'normal';
|
|
681
|
+
const priority = (priorityEl?.value || 'normal') as Priority;
|
|
1412
682
|
const tags = (tagsEl?.value || '')
|
|
1413
683
|
.split(',')
|
|
1414
|
-
.map((
|
|
684
|
+
.map((s) => s.trim())
|
|
1415
685
|
.filter(Boolean);
|
|
1416
686
|
|
|
1417
687
|
try {
|
|
1418
|
-
const
|
|
688
|
+
const updated = await api.put<Task>(`/tasks/${encodeURIComponent(task.id)}`, {
|
|
1419
689
|
title,
|
|
1420
690
|
description,
|
|
1421
691
|
priority,
|
|
1422
692
|
tags,
|
|
1423
693
|
});
|
|
1424
|
-
const count = (result.subtasks || []).length;
|
|
1425
|
-
toast.success(
|
|
1426
|
-
count > 1
|
|
1427
|
-
? `Odin split it into ${count} subtasks.`
|
|
1428
|
-
: 'Task submitted to Odin.',
|
|
1429
|
-
);
|
|
1430
|
-
// v3.3.1 — CRITICAL: close modal FIRST, then bump safe window,
|
|
1431
|
-
// then update state. This way the portal unmount + React re-render
|
|
1432
|
-
// can't trigger any key handlers in the brief window before state
|
|
1433
|
-
// updates. We manually dispatch a synthetic mousedown to extend
|
|
1434
|
-
// the safe window so any stray keyboard events during the state-
|
|
1435
|
-
// update tick also can't trigger tab switches.
|
|
1436
694
|
modal.close();
|
|
1437
695
|
if (typeof window !== 'undefined') {
|
|
1438
696
|
window.dispatchEvent(new MouseEvent('mousedown'));
|
|
1439
697
|
}
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
698
|
+
setTasks((cur) => cur.map((x) => (x.id === task.id ? updated : x)));
|
|
699
|
+
toast.success('Task updated.', 1200);
|
|
700
|
+
await refreshSnapshot();
|
|
1443
701
|
} catch (err) {
|
|
1444
|
-
toast.error(`
|
|
1445
|
-
}
|
|
1446
|
-
};
|
|
1447
|
-
|
|
1448
|
-
// v3.3.0 — Submit on Enter from the title input. Without an
|
|
1449
|
-
// explicit onKeyDown, the implicit form-submit behavior can fire
|
|
1450
|
-
// when a user presses Enter. Even though we don't wrap inputs in
|
|
1451
|
-
// <form>, this is the safest pattern.
|
|
1452
|
-
const onTitleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
1453
|
-
if (e.key === 'Enter' && !e.shiftKey) {
|
|
1454
|
-
e.preventDefault();
|
|
1455
|
-
onSubmit(e);
|
|
702
|
+
toast.error(`Save failed: ${(err as Error).message}`);
|
|
1456
703
|
}
|
|
1457
704
|
};
|
|
1458
705
|
|
|
1459
706
|
modal.open({
|
|
1460
|
-
title: '
|
|
1461
|
-
width:
|
|
707
|
+
title: 'Edit task',
|
|
708
|
+
width: 520,
|
|
1462
709
|
children: (
|
|
1463
|
-
<div className="
|
|
1464
|
-
<label htmlFor="
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
<textarea
|
|
1483
|
-
id="submit-task-desc"
|
|
1484
|
-
ref={(el) => { descEl = el; }}
|
|
1485
|
-
className="textarea"
|
|
1486
|
-
rows={5}
|
|
1487
|
-
placeholder="Provide more detail (markdown ok)…"
|
|
1488
|
-
style={{ flex: 1 }}
|
|
1489
|
-
/>
|
|
1490
|
-
<button
|
|
1491
|
-
type="button"
|
|
1492
|
-
className="btn btn-ghost btn-icon"
|
|
1493
|
-
style={{ marginTop: 0, flexShrink: 0 }}
|
|
1494
|
-
title="Enhance prompt with AI"
|
|
1495
|
-
onClick={async () => {
|
|
1496
|
-
if (!descEl?.value?.trim()) return;
|
|
1497
|
-
const enhanced = await enhancePrompt(descEl.value);
|
|
1498
|
-
if (enhanced !== descEl.value) descEl.value = enhanced;
|
|
1499
|
-
}}
|
|
1500
|
-
>
|
|
1501
|
-
<Sparkles size={14} />
|
|
1502
|
-
</button>
|
|
1503
|
-
</span>
|
|
1504
|
-
</label>
|
|
710
|
+
<div className="task-form">
|
|
711
|
+
<label className="field-label" htmlFor="edit-task-title">Title *</label>
|
|
712
|
+
<input
|
|
713
|
+
ref={(el) => { titleEl = el; }}
|
|
714
|
+
id="edit-task-title"
|
|
715
|
+
className="input"
|
|
716
|
+
type="text"
|
|
717
|
+
maxLength={200}
|
|
718
|
+
defaultValue={task.title}
|
|
719
|
+
autoFocus
|
|
720
|
+
/>
|
|
721
|
+
<label className="field-label" htmlFor="edit-task-desc">Description</label>
|
|
722
|
+
<textarea
|
|
723
|
+
ref={(el) => { descEl = el; }}
|
|
724
|
+
id="edit-task-desc"
|
|
725
|
+
className="textarea"
|
|
726
|
+
rows={4}
|
|
727
|
+
defaultValue={task.description || ''}
|
|
728
|
+
/>
|
|
1505
729
|
<div className="task-form-row">
|
|
1506
|
-
<label htmlFor="
|
|
1507
|
-
Priority
|
|
730
|
+
<label htmlFor="edit-task-priority" className="task-form-field">
|
|
731
|
+
Priority hint
|
|
1508
732
|
<select
|
|
1509
|
-
id="submit-task-priority"
|
|
1510
733
|
ref={(el) => { priorityEl = el; }}
|
|
734
|
+
id="edit-task-priority"
|
|
1511
735
|
className="select"
|
|
1512
|
-
defaultValue=
|
|
736
|
+
defaultValue={task.priority || 'normal'}
|
|
1513
737
|
>
|
|
1514
738
|
{PRIORITIES.map((p) => (
|
|
1515
739
|
<option key={p} value={p}>{p}</option>
|
|
1516
740
|
))}
|
|
1517
741
|
</select>
|
|
1518
742
|
</label>
|
|
1519
|
-
<label htmlFor="
|
|
1520
|
-
Tags
|
|
743
|
+
<label htmlFor="edit-task-tags" className="task-form-field" style={{ flex: 2 }}>
|
|
744
|
+
Tags
|
|
1521
745
|
<input
|
|
1522
|
-
id="submit-task-tags"
|
|
1523
746
|
ref={(el) => { tagsEl = el; }}
|
|
747
|
+
id="edit-task-tags"
|
|
1524
748
|
className="input"
|
|
1525
749
|
type="text"
|
|
1526
|
-
|
|
750
|
+
defaultValue={(task.tags || []).join(', ')}
|
|
751
|
+
placeholder="comma-separated"
|
|
1527
752
|
/>
|
|
1528
753
|
</label>
|
|
1529
754
|
</div>
|
|
755
|
+
<Card>
|
|
756
|
+
<CardTitle><TagIcon size={12} /> Status</CardTitle>
|
|
757
|
+
<CardMeta>
|
|
758
|
+
Current: <strong>{task.status}</strong>
|
|
759
|
+
{task.workedBy && <> · Worked by @{task.workedBy}</>}
|
|
760
|
+
{task.assignee && !task.workedBy && <> · Assigned @{task.assignee}</>}
|
|
761
|
+
</CardMeta>
|
|
762
|
+
</Card>
|
|
1530
763
|
</div>
|
|
1531
764
|
),
|
|
1532
765
|
footer: (
|
|
1533
766
|
<div className="modal-footer-actions">
|
|
1534
767
|
<Button variant="ghost" onClick={() => modal.close()}>Cancel</Button>
|
|
1535
|
-
<Button
|
|
1536
|
-
|
|
1537
|
-
type="button"
|
|
1538
|
-
onClick={(e) => onSubmit(e)}
|
|
1539
|
-
>
|
|
1540
|
-
<Send size={14} /> Submit to Odin
|
|
768
|
+
<Button variant="primary" onClick={submit}>
|
|
769
|
+
<ArchiveRestore size={14} /> Save
|
|
1541
770
|
</Button>
|
|
1542
771
|
</div>
|
|
1543
772
|
),
|
|
1544
773
|
});
|
|
1545
|
-
}
|
|
774
|
+
}
|