@polderlabs/bizar 4.4.12 → 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.
Files changed (91) hide show
  1. package/bizar-dash/CHANGELOG.md +37 -276
  2. package/bizar-dash/dist/assets/main-CDFKHzBg.css +1 -0
  3. package/bizar-dash/dist/assets/main-NYFpS2wY.js +312 -0
  4. package/bizar-dash/dist/assets/main-NYFpS2wY.js.map +1 -0
  5. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile--0FBIKX3.js} +2 -2
  6. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile--0FBIKX3.js.map} +1 -1
  7. package/bizar-dash/dist/assets/mobile-OgRp8VIb.js +352 -0
  8. package/bizar-dash/dist/assets/mobile-OgRp8VIb.js.map +1 -0
  9. package/bizar-dash/dist/index.html +3 -3
  10. package/bizar-dash/dist/mobile.html +2 -2
  11. package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
  12. package/bizar-dash/skills/bizar/SKILL.md +96 -0
  13. package/bizar-dash/skills/chat/SKILL.md +74 -0
  14. package/bizar-dash/skills/lightrag/SKILL.md +75 -0
  15. package/bizar-dash/skills/minimax/SKILL.md +80 -0
  16. package/bizar-dash/skills/obsidian/SKILL.md +55 -0
  17. package/bizar-dash/skills/providers/SKILL.md +75 -0
  18. package/bizar-dash/skills/sdk/SKILL.md +138 -0
  19. package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
  20. package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
  21. package/bizar-dash/skills/usage/SKILL.md +62 -0
  22. package/bizar-dash/src/server/api.mjs +12 -0
  23. package/bizar-dash/src/server/memory-lightrag.mjs +5 -2
  24. package/bizar-dash/src/server/memory-store.mjs +38 -0
  25. package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
  26. package/bizar-dash/src/server/minimax.mjs +427 -110
  27. package/bizar-dash/src/server/providers-store.mjs +966 -6
  28. package/bizar-dash/src/server/routes/config.mjs +52 -1
  29. package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
  30. package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
  31. package/bizar-dash/src/server/routes/memory.mjs +241 -1
  32. package/bizar-dash/src/server/routes/minimax.mjs +50 -57
  33. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
  34. package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
  35. package/bizar-dash/src/server/routes/providers.mjs +266 -5
  36. package/bizar-dash/src/server/routes/skills.mjs +32 -43
  37. package/bizar-dash/src/server/routes/update.mjs +340 -0
  38. package/bizar-dash/src/server/routes/usage.mjs +136 -0
  39. package/bizar-dash/src/server/serve-info.mjs +135 -4
  40. package/bizar-dash/src/server/server.mjs +4 -0
  41. package/bizar-dash/src/server/skills-store.mjs +152 -262
  42. package/bizar-dash/src/web/App.tsx +118 -29
  43. package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
  44. package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
  45. package/bizar-dash/src/web/components/Topbar.tsx +0 -1
  46. package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
  47. package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
  48. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
  49. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
  50. package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
  51. package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
  52. package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
  53. package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
  54. package/bizar-dash/src/web/lib/api.ts +43 -0
  55. package/bizar-dash/src/web/main.tsx +1 -0
  56. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
  57. package/bizar-dash/src/web/styles/chat.css +135 -1
  58. package/bizar-dash/src/web/styles/main.css +46 -0
  59. package/bizar-dash/src/web/styles/minimax-usage.css +471 -0
  60. package/bizar-dash/src/web/styles/settings.css +418 -0
  61. package/bizar-dash/src/web/styles/skills.css +302 -0
  62. package/bizar-dash/src/web/styles/tasks.css +288 -0
  63. package/bizar-dash/src/web/views/Chat.tsx +276 -48
  64. package/bizar-dash/src/web/views/Config.tsx +3 -2065
  65. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +620 -240
  66. package/bizar-dash/src/web/views/Settings.tsx +6 -0
  67. package/bizar-dash/src/web/views/Skills.tsx +208 -260
  68. package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
  69. package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
  70. package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
  71. package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
  72. package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
  73. package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
  74. package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
  75. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
  76. package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
  77. package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
  78. package/bizar-dash/tests/skills-list.test.mjs +232 -0
  79. package/bizar-dash/tests/skills-search.test.mjs +222 -0
  80. package/bizar-dash/tests/tasks-create.test.mjs +187 -0
  81. package/bizar-dash/tests/update-check.test.mjs +127 -0
  82. package/bizar-dash/tests/update-run.test.mjs +266 -0
  83. package/cli/bin.mjs +345 -4
  84. package/cli/provision.mjs +118 -4
  85. package/config/agents/_shared/SKILLS.md +109 -0
  86. package/package.json +1 -1
  87. package/bizar-dash/dist/assets/main-BKXEqU1w.css +0 -1
  88. package/bizar-dash/dist/assets/main-EK_fzXn_.js +0 -352
  89. package/bizar-dash/dist/assets/main-EK_fzXn_.js.map +0 -1
  90. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
  91. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
@@ -1,4 +1,25 @@
1
- // src/views/Tasks.tsx — v3.1.0 task board: archive, filters, sort, bulk, recurring, timers, comments, real-time.
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, autoTitleFromContent } from '../lib/utils';
47
- import type { Agent, Settings, Snapshot, Task } from '../lib/types';
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 Column = {
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
- * v3.7.4 Decide whether a task belongs in the given kanban column.
74
- * Archived tasks (any prior session's "finished and tucked away" tasks)
75
- * show up in the DONE column so a fresh dashboard load still surfaces
76
- * everything that exists on disk.
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
- function matchesColumn(task: Task, colId: string): boolean {
79
- const isArchived = task.status === 'archived' || task.archived === true;
80
- if (colId === 'done') return task.status === 'done' || isArchived;
81
- return !isArchived && task.status === colId;
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 [tagFilter, setTagFilter] = useState<string>('');
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 (opts: { archived?: boolean } = {}) => {
89
+ const reload = async () => {
110
90
  try {
111
- const params = opts.archived ? '?archived=only' : '';
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 for live timer display.
107
+ // Periodic tick keeps relative-time labels fresh without a full reload.
129
108
  useEffect(() => {
130
- const id = setInterval(() => setTick((t) => t + 1), 1000);
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
- const title = (t.title || '').toLowerCase();
155
- const desc = (t.description || '').toLowerCase();
156
- const tags = (t.tags || []).join(' ').toLowerCase();
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
- // eslint-disable-next-line react-hooks/exhaustive-deps
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
- switch (sortKey) {
168
- case 'priority':
169
- out.sort((a, b) => (priorityWeight[a.priority] ?? 1) - (priorityWeight[b.priority] ?? 1));
170
- break;
171
- case 'created':
172
- out.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
173
- break;
174
- case 'due':
175
- out.sort((a, b) => {
176
- const ax = a.dueDate ? new Date(a.dueDate).getTime() : Infinity;
177
- const bx = b.dueDate ? new Date(b.dueDate).getTime() : Infinity;
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}.`, 1500);
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.', 1500);
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 archiveTask = async (taskId: string) => {
173
+ const retryTask = async (taskId: string) => {
222
174
  try {
223
- const updated = await api.post<Task>(`/tasks/${encodeURIComponent(taskId)}/archive`);
224
- setTasks((cur) => cur.filter((t) => t.id !== taskId));
225
- toast.success('Task archived.', 1500);
226
- } catch (err) {
227
- toast.error(`Archive failed: ${(err as Error).message}`);
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(`Bulk ${action}: ${ok} ok${failed ? `, ${failed} failed` : ''}.`, 2500);
258
- setSelected(new Set());
183
+ toast.success('Retry dispatched.', 1200);
259
184
  } catch (err) {
260
- toast.error(`Bulk failed: ${(err as Error).message}`);
185
+ toast.error(`Retry failed: ${(err as Error).message}`);
261
186
  }
262
187
  };
263
188
 
264
- const onAssignMe = async (t: Task) => {
265
- try {
266
- const me = 'me';
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
- const updated = await api.post<Task>(`/tasks/${encodeURIComponent(t.id)}/work`, {
278
- agent,
279
- status,
280
- complete,
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
- setTasks((cur) => cur.map((x) => (x.id === t.id ? updated : x)));
283
- toast.success(`Marked ${status} by ${agent}.`, 1500);
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(`Mark failed: ${(err as Error).message}`);
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
- Personal kanban. Click a card for details (subtasks, deps, timer, comments, activity).
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
- {/* v3.4.0 — Compact single-row toolbar: search + filters + sort + actions */}
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">Filter</span>
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="Priority"
264
+ title="Filter by priority"
387
265
  >
388
- <option value="">All priorities</option>
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 agents={snapshot.agents || []} onRefresh={refreshSnapshot} />
473
- )}
474
-
475
- {selected.size > 0 && (
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
- // v3.7.4 Archived tasks (`status: "archived"` OR `archived: true`)
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
- onArchive={archiveTask}
527
- onUnarchive={unarchiveTask}
528
- onEdit={(t) => openTaskDetail(modal, toast, t, setTasks, reload, refreshSnapshot, snapshot.agents, tick)}
529
- onAdd={() => openTaskModal(modal, toast, null, col.id, setTasks, reload, refreshSnapshot, snapshot.agents)}
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
- onArchive,
553
- onUnarchive,
338
+ onRetry,
554
339
  onEdit,
555
- onAdd,
556
- onAssignMe,
557
- onMarkWorkedBy,
558
- onOpenChat,
559
- onOpenArtifact,
560
- isArchivedView,
340
+ onSubmitToOdin,
341
+ tick,
561
342
  }: {
562
- column: 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
- onArchive: (id: string) => void;
571
- onUnarchive: (id: string) => void;
572
- onEdit: (task: Task) => void;
573
- onAdd: () => void;
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
- <StatusBadge kind={column.kind} dot>
610
- {column.label}
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
- onArchive={() => onArchive(t.id)}
634
- onUnarchive={() => onUnarchive(t.id)}
635
- onAssignMe={() => onAssignMe(t)}
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
- onArchive,
663
- onUnarchive,
664
- onAssignMe,
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
- onArchive: () => void;
677
- onUnarchive: () => void;
678
- onAssignMe: () => void;
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 toast = useToast();
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
- data-task-parent={task.parent || ''}
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
- onClick={onFocus}
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
- {task.assignee && !task.workedBy && <span className="task-card-badge"><TagIcon size={10} /> @{task.assignee}</span>}
755
- {task.timeSpent ? <span className="task-card-badge"><Clock size={10} /> {formatDuration(task.timeSpent)}</span> : null}
756
- {task.dependencies?.length ? <span className="task-card-badge"><Link2 size={10} /> {task.dependencies.length}</span> : null}
757
- {task.recurring ? <span className="task-card-badge"><Calendar size={10} /> {task.recurring.cron || task.recurring.freq || 'recurring'}</span> : null}
758
- {task.comments?.length ? <span className="task-card-badge"><MessageSquare size={10} /> {task.comments.length}</span> : null}
759
- {task.attachments?.length ? <span className="task-card-badge"><Paperclip size={10} /> {task.attachments.length}</span> : null}
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
- <Activity size={10} /> <BgStatusBadge status={metadata.status || 'pending'} />
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.updatedAt || task.createdAt)}
457
+ {formatRelative(task.createdAt)}
776
458
  </span>
777
459
  <div className="task-card-actions" onClick={(e) => e.stopPropagation()}>
778
- {!isArchivedView && (
779
- <>
780
- <button
781
- type="button"
782
- className="icon-btn"
783
- aria-label="Move left"
784
- title="Move left"
785
- onClick={() => onMove(-1)}
786
- >
787
- <ChevronLeft size={14} />
788
- </button>
789
- <button
790
- type="button"
791
- className="icon-btn"
792
- aria-label="Edit"
793
- title="Open detail"
794
- onClick={onEdit}
795
- >
796
- <Sparkles size={14} />
797
- </button>
798
- <button
799
- type="button"
800
- className="icon-btn"
801
- aria-label="Assign me"
802
- title="Assign to me"
803
- onClick={onAssignMe}
804
- >
805
- <TagIcon size={14} />
806
- </button>
807
- {hasChatSession && (
808
- <button
809
- type="button"
810
- className="icon-btn"
811
- aria-label="Open chat"
812
- title="Open in chat"
813
- onClick={() => onOpenChat(task.id)}
814
- >
815
- <MessageSquare size={14} />
816
- </button>
817
- )}
818
- {primaryArtifactId && (
819
- <button
820
- type="button"
821
- className="icon-btn"
822
- aria-label="Open artifact"
823
- title="Open artifact"
824
- onClick={() => onOpenArtifact(primaryArtifactId)}
825
- >
826
- <FileText size={14} />
827
- </button>
828
- )}
829
- {isArchivedView ? (
830
- <button
831
- type="button"
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
- function formatDuration(seconds: number): string {
918
- if (!seconds) return '0m';
919
- const h = Math.floor(seconds / 3600);
920
- const m = Math.floor((seconds % 3600) / 60);
921
- if (h > 0) return `${h}h ${m}m`;
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
- export function openTaskModal(
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: HTMLDivElement | null = null;
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
- const title = titleEl?.value.trim() || '';
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 description = descEl?.value.trim() || '';
953
- const priority =
954
- (priorityEl?.querySelector<HTMLInputElement>(
955
- 'input[name="task-priority"]:checked',
956
- )?.value as Priority) || 'normal';
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
- if (task) {
974
- const updated = await api.put<Task>(`/tasks/${encodeURIComponent(task.id)}`, {
975
- title,
976
- description,
977
- tags,
978
- priority,
979
- status,
980
- assignee,
981
- recurring,
982
- dueDate,
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(`Save failed: ${(err as Error).message}`);
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: task ? 'Edit task' : 'New task',
1018
- width: 560,
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 (markdown)</label>
1033
- <textarea
1034
- ref={(el) => { descEl = el; }}
1035
- id="task-desc"
1036
- className="textarea"
1037
- rows={3}
1038
- placeholder="Markdown supported…"
1039
- defaultValue={task?.description || ''}
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
- <div className="task-form-field">
1043
- <label className="field-label" htmlFor="task-status">Status</label>
620
+ <label htmlFor="task-priority" className="task-form-field">
621
+ Priority hint
1044
622
  <select
1045
- ref={(el) => { statusEl = el; }}
1046
- id="task-status"
623
+ ref={(el) => { priorityEl = el; }}
624
+ id="task-priority"
1047
625
  className="select"
1048
- defaultValue={task?.status || initialStatus}
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
- <label key={p} className="radio-label">
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
- </div>
1126
- <div className="task-form-field">
1127
- <label className="field-label" htmlFor="task-cron">Cron expression</label>
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) => { cronEl = el; }}
1130
- id="task-cron"
636
+ ref={(el) => { tagsEl = el; }}
637
+ id="task-tags"
1131
638
  className="input"
1132
639
  type="text"
1133
- placeholder="0 9 * * *"
1134
- defaultValue={task?.recurring?.cron || ''}
640
+ placeholder="bug, frontend, urgent"
1135
641
  />
1136
- </div>
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={submit}>{task ? 'Save' : 'Create'}</Button>
1144
- </div>
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
- // v3.2.0 — Submit a task to Odin. Odin splits it into subtasks and
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
- // v3.3.0 — Wraps the submit click so we can preventDefault /
1390
- // stopPropagation. Without this, a click on a button could bubble
1391
- // up through the modal portal after the modal closes and either
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('Add a description so Odin knows what to do.');
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((t: string) => t.trim())
684
+ .map((s) => s.trim())
1415
685
  .filter(Boolean);
1416
686
 
1417
687
  try {
1418
- const result = await api.post<{ main: Task; subtasks: Task[] }>('/tasks/submit', {
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
- if (setTasks) setTasks((cur: Task[]) => [result.main, ...(result.subtasks || []), ...cur]);
1441
- if (reload) await reload();
1442
- if (refreshSnapshot) await refreshSnapshot();
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(`Submit failed: ${(err as Error).message}`);
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: 'Submit Task to Odin',
1461
- width: 560,
707
+ title: 'Edit task',
708
+ width: 520,
1462
709
  children: (
1463
- <div className="submit-task-form">
1464
- <label htmlFor="submit-task-title">
1465
- Title <span className="field-hint">(optional — auto-generated from description)</span>
1466
- <input
1467
- id="submit-task-title"
1468
- ref={(el) => { titleEl = el; }}
1469
- className="input"
1470
- type="text"
1471
- placeholder="Leave blank to derive from description"
1472
- autoFocus
1473
- onKeyDown={onTitleKeyDown}
1474
- />
1475
- <span className="field-hint">
1476
- Odin will analyze this and split it into subtasks assigned to the best agent.
1477
- </span>
1478
- </label>
1479
- <label htmlFor="submit-task-desc">
1480
- Description
1481
- <span style={{ display: 'flex', gap: 6, alignItems: 'flex-start' }}>
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="submit-task-priority" className="task-form-field">
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="normal"
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="submit-task-tags" className="task-form-field" style={{ flex: 2 }}>
1520
- Tags <span className="field-hint">(comma-separated)</span>
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
- placeholder="backend, auth"
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
- variant="primary"
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
+ }