@creezio/tasks 0.22.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 (106) hide show
  1. package/README.md +279 -0
  2. package/dist/.creezio-src-hash.json +4 -0
  3. package/dist/ai-task-agent.d.ts +47 -0
  4. package/dist/ai-task-agent.d.ts.map +1 -0
  5. package/dist/ai-task-agent.js +558 -0
  6. package/dist/ai-task-agent.js.map +1 -0
  7. package/dist/ai-task-runner.d.ts +78 -0
  8. package/dist/ai-task-runner.d.ts.map +1 -0
  9. package/dist/ai-task-runner.js +645 -0
  10. package/dist/ai-task-runner.js.map +1 -0
  11. package/dist/api-mount.d.ts +4 -0
  12. package/dist/api-mount.d.ts.map +1 -0
  13. package/dist/api-mount.js +91 -0
  14. package/dist/api-mount.js.map +1 -0
  15. package/dist/assistant-adapter.d.ts +7 -0
  16. package/dist/assistant-adapter.d.ts.map +1 -0
  17. package/dist/assistant-adapter.js +96 -0
  18. package/dist/assistant-adapter.js.map +1 -0
  19. package/dist/brand/config.d.ts +165 -0
  20. package/dist/brand/config.d.ts.map +1 -0
  21. package/dist/brand/config.js +39 -0
  22. package/dist/brand/config.js.map +1 -0
  23. package/dist/env-bridge.d.ts +12 -0
  24. package/dist/env-bridge.d.ts.map +1 -0
  25. package/dist/env-bridge.js +33 -0
  26. package/dist/env-bridge.js.map +1 -0
  27. package/dist/hono-routes.d.ts +8 -0
  28. package/dist/hono-routes.d.ts.map +1 -0
  29. package/dist/hono-routes.js +563 -0
  30. package/dist/hono-routes.js.map +1 -0
  31. package/dist/index.d.ts +29 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +21 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/kanban-service.d.ts +136 -0
  36. package/dist/kanban-service.d.ts.map +1 -0
  37. package/dist/kanban-service.js +580 -0
  38. package/dist/kanban-service.js.map +1 -0
  39. package/dist/mcp-host-tools.d.ts +59 -0
  40. package/dist/mcp-host-tools.d.ts.map +1 -0
  41. package/dist/mcp-host-tools.js +326 -0
  42. package/dist/mcp-host-tools.js.map +1 -0
  43. package/dist/mcp-workspace-tools.d.ts +15 -0
  44. package/dist/mcp-workspace-tools.d.ts.map +1 -0
  45. package/dist/mcp-workspace-tools.js +338 -0
  46. package/dist/mcp-workspace-tools.js.map +1 -0
  47. package/dist/memory-store.d.ts +3 -0
  48. package/dist/memory-store.d.ts.map +1 -0
  49. package/dist/memory-store.js +75 -0
  50. package/dist/memory-store.js.map +1 -0
  51. package/dist/sqlite-driver.d.ts +13 -0
  52. package/dist/sqlite-driver.d.ts.map +1 -0
  53. package/dist/sqlite-driver.js +13 -0
  54. package/dist/sqlite-driver.js.map +1 -0
  55. package/dist/sqlite-store.d.ts +15 -0
  56. package/dist/sqlite-store.d.ts.map +1 -0
  57. package/dist/sqlite-store.js +113 -0
  58. package/dist/sqlite-store.js.map +1 -0
  59. package/dist/task-runs.d.ts +108 -0
  60. package/dist/task-runs.d.ts.map +1 -0
  61. package/dist/task-runs.js +427 -0
  62. package/dist/task-runs.js.map +1 -0
  63. package/dist/types.d.ts +35 -0
  64. package/dist/types.d.ts.map +1 -0
  65. package/dist/types.js +15 -0
  66. package/dist/types.js.map +1 -0
  67. package/dist-cjs/ai-task-agent.js +569 -0
  68. package/dist-cjs/ai-task-agent.js.map +1 -0
  69. package/dist-cjs/ai-task-runner.js +660 -0
  70. package/dist-cjs/ai-task-runner.js.map +1 -0
  71. package/dist-cjs/api-mount.js +94 -0
  72. package/dist-cjs/api-mount.js.map +1 -0
  73. package/dist-cjs/assistant-adapter.js +99 -0
  74. package/dist-cjs/assistant-adapter.js.map +1 -0
  75. package/dist-cjs/brand/config.js +47 -0
  76. package/dist-cjs/brand/config.js.map +1 -0
  77. package/dist-cjs/env-bridge.js +36 -0
  78. package/dist-cjs/env-bridge.js.map +1 -0
  79. package/dist-cjs/hono-routes.js +566 -0
  80. package/dist-cjs/hono-routes.js.map +1 -0
  81. package/dist-cjs/index.js +117 -0
  82. package/dist-cjs/index.js.map +1 -0
  83. package/dist-cjs/kanban-service.js +601 -0
  84. package/dist-cjs/kanban-service.js.map +1 -0
  85. package/dist-cjs/mcp-host-tools.js +333 -0
  86. package/dist-cjs/mcp-host-tools.js.map +1 -0
  87. package/dist-cjs/mcp-workspace-tools.js +342 -0
  88. package/dist-cjs/mcp-workspace-tools.js.map +1 -0
  89. package/dist-cjs/memory-store.js +81 -0
  90. package/dist-cjs/memory-store.js.map +1 -0
  91. package/dist-cjs/package.json +3 -0
  92. package/dist-cjs/sqlite-driver.js +16 -0
  93. package/dist-cjs/sqlite-driver.js.map +1 -0
  94. package/dist-cjs/sqlite-store.js +119 -0
  95. package/dist-cjs/sqlite-store.js.map +1 -0
  96. package/dist-cjs/task-runs.js +457 -0
  97. package/dist-cjs/task-runs.js.map +1 -0
  98. package/dist-cjs/types.js +18 -0
  99. package/dist-cjs/types.js.map +1 -0
  100. package/package.json +79 -0
  101. package/scripts/port-from-tempoflow.mjs +405 -0
  102. package/ui/ai-activity-panel.tsx +559 -0
  103. package/ui/index.ts +7 -0
  104. package/ui/task-detail-sheet.tsx +668 -0
  105. package/ui/tasks-kanban-client.tsx +647 -0
  106. package/ui/tasks-types.ts +120 -0
@@ -0,0 +1,647 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
+ import {
5
+ Bot,
6
+ CheckCircle2,
7
+ ChevronRight,
8
+ Loader2,
9
+ Plus,
10
+ RefreshCw,
11
+ Repeat2,
12
+ Search,
13
+ Sparkles,
14
+ User,
15
+ } from "lucide-react";
16
+ import { formatDistanceToNow } from "date-fns";
17
+ import { fr } from "date-fns/locale";
18
+ import { toast } from "sonner";
19
+ import { Badge } from "@creezio/shell-ui/ui/kit";
20
+ import { Button } from "@creezio/shell-ui/ui/kit";
21
+ import { Input } from "@creezio/shell-ui/ui/kit";
22
+ import { cn } from "@creezio/shell-ui";
23
+ import { TaskDetailSheet } from "./task-detail-sheet";
24
+ import {
25
+ EXECUTOR_META,
26
+ KANBAN_COLUMNS,
27
+ previewBrief,
28
+ STATUS_META,
29
+ sourceLabel,
30
+ type ColumnKey,
31
+ type TaskAssignee,
32
+ type TaskCard,
33
+ } from "./tasks-types";
34
+
35
+ type ExecutorFilter = "all" | TaskCard["executor_kind"];
36
+
37
+ const EXECUTOR_FILTERS: Array<{ id: ExecutorFilter; label: string }> = [
38
+ { id: "all", label: "Tous" },
39
+ { id: "human", label: "Humains" },
40
+ { id: "ai", label: "IA" },
41
+ { id: "hermes", label: "Hermes" },
42
+ ];
43
+
44
+ export function TasksKanbanClient() {
45
+ const [columns, setColumns] = useState<Record<string, TaskCard[]>>({});
46
+ const [hermesConfigured, setHermesConfigured] = useState(false);
47
+ const [bridgeReady, setBridgeReady] = useState(false);
48
+ const [assignees, setAssignees] = useState<TaskAssignee[]>([]);
49
+ const [loading, setLoading] = useState(true);
50
+ const [syncing, setSyncing] = useState(false);
51
+
52
+ // Composer
53
+ const [composerOpen, setComposerOpen] = useState(false);
54
+ const [title, setTitle] = useState("");
55
+ const [body, setBody] = useState("");
56
+ const [executor, setExecutor] = useState<TaskCard["executor_kind"]>("human");
57
+ const [assigneeId, setAssigneeId] = useState("");
58
+ const [schedule, setSchedule] = useState("");
59
+ const [launchNow, setLaunchNow] = useState(true);
60
+ const [creating, setCreating] = useState(false);
61
+
62
+ // Board
63
+ const [filter, setFilter] = useState("");
64
+ const [executorFilter, setExecutorFilter] = useState<ExecutorFilter>("all");
65
+ // Inbox missions d'un collaborateur (P4) : ?assignee=<userId> — utilisé par
66
+ // le workspace IA (atterrissage) et les liens « Ses tâches ».
67
+ const [assigneeFilterId, setAssigneeFilterId] = useState("");
68
+ const [selectedId, setSelectedId] = useState<string | null>(null);
69
+ const [sheetOpen, setSheetOpen] = useState(false);
70
+ const [dragId, setDragId] = useState<string | null>(null);
71
+ const [dropCol, setDropCol] = useState<ColumnKey | null>(null);
72
+
73
+ const load = useCallback(async (sync = true) => {
74
+ try {
75
+ const res = await fetch(`/api/v1/tasks?sync=${sync ? "1" : "0"}`);
76
+ const data = (await res.json()) as {
77
+ columns?: Record<string, TaskCard[]>;
78
+ hermes_configured?: boolean;
79
+ host_bridge_ready?: boolean;
80
+ error?: string;
81
+ sync?: { errors?: string[] } | null;
82
+ };
83
+ if (!res.ok) {
84
+ toast.error(data.error || `Erreur ${res.status}`);
85
+ return;
86
+ }
87
+ setColumns(data.columns || {});
88
+ setHermesConfigured(Boolean(data.hermes_configured));
89
+ setBridgeReady(Boolean(data.host_bridge_ready));
90
+ if (data.sync?.errors?.length) {
91
+ toast.message(`Sync Hermes : ${data.sync.errors[0]}`);
92
+ }
93
+ } finally {
94
+ setLoading(false);
95
+ }
96
+ }, []);
97
+
98
+ useEffect(() => {
99
+ // Filtre initial via ?q= (ex. lien « Ses tâches » depuis Collaborateurs).
100
+ const sp = new URLSearchParams(window.location.search);
101
+ const q = sp.get("q");
102
+ if (q) setFilter(q);
103
+ const assignee = sp.get("assignee");
104
+ if (assignee) setAssigneeFilterId(assignee);
105
+ }, []);
106
+
107
+ useEffect(() => {
108
+ void load();
109
+ void fetch("/api/v1/tasks/meta")
110
+ .then((r) => (r.ok ? r.json() : null))
111
+ .then((d: { assignable_users?: TaskAssignee[] } | null) => {
112
+ if (d?.assignable_users) setAssignees(d.assignable_users);
113
+ })
114
+ .catch(() => {});
115
+ // Poll léger sans re-sync Hermes à chaque fois (sync toutes les 3 itérations).
116
+ let tick = 0;
117
+ const t = setInterval(() => {
118
+ tick += 1;
119
+ void load(tick % 3 === 0);
120
+ }, 15000);
121
+ return () => clearInterval(t);
122
+ }, [load]);
123
+
124
+ const eligibleAssignees = useMemo(() => {
125
+ if (executor === "hermes") return [];
126
+ return assignees.filter((a) => a.kind === (executor === "ai" ? "ai" : "human"));
127
+ }, [assignees, executor]);
128
+
129
+ useEffect(() => {
130
+ // L'assigné doit rester cohérent avec l'exécutant choisi.
131
+ if (executor === "hermes") {
132
+ setAssigneeId("");
133
+ return;
134
+ }
135
+ if (assigneeId && !eligibleAssignees.some((a) => a.id === assigneeId)) {
136
+ setAssigneeId("");
137
+ }
138
+ if (executor === "ai" && !assigneeId && eligibleAssignees.length === 1) {
139
+ setAssigneeId(eligibleAssignees[0]!.id);
140
+ }
141
+ }, [executor, assigneeId, eligibleAssignees]);
142
+
143
+ const filteredColumns = useMemo(() => {
144
+ const q = filter.trim().toLowerCase();
145
+ const out: Record<string, TaskCard[]> = {};
146
+ for (const [k, list] of Object.entries(columns)) {
147
+ out[k] = (list || []).filter((t) => {
148
+ if (executorFilter !== "all" && t.executor_kind !== executorFilter) {
149
+ return false;
150
+ }
151
+ if (assigneeFilterId && t.assignee?.id !== assigneeFilterId) {
152
+ return false;
153
+ }
154
+ if (!q) return true;
155
+ return (
156
+ t.title.toLowerCase().includes(q) ||
157
+ t.body.toLowerCase().includes(q) ||
158
+ (t.assignee?.username || "").toLowerCase().includes(q)
159
+ );
160
+ });
161
+ }
162
+ return out;
163
+ }, [columns, filter, executorFilter, assigneeFilterId]);
164
+
165
+ const totalCount = useMemo(
166
+ () =>
167
+ Object.values(columns).reduce((acc, list) => acc + (list?.length || 0), 0),
168
+ [columns],
169
+ );
170
+
171
+ async function syncNow() {
172
+ setSyncing(true);
173
+ try {
174
+ const res = await fetch("/api/v1/tasks/sync", { method: "POST" });
175
+ const data = (await res.json()) as {
176
+ columns?: Record<string, TaskCard[]>;
177
+ sync?: { updated: number; created: number };
178
+ error?: string;
179
+ };
180
+ if (!res.ok) {
181
+ toast.error(data.error || "Sync impossible");
182
+ return;
183
+ }
184
+ setColumns(data.columns || {});
185
+ toast.success(
186
+ `Sync Hermes : +${data.sync?.created ?? 0} créées · ${data.sync?.updated ?? 0} mises à jour`,
187
+ );
188
+ } finally {
189
+ setSyncing(false);
190
+ }
191
+ }
192
+
193
+ async function createTask(e: React.FormEvent) {
194
+ e.preventDefault();
195
+ if (!title.trim()) return;
196
+ if (executor === "ai" && !assigneeId) {
197
+ toast.error("Choisissez le collaborateur IA qui exécutera la tâche");
198
+ return;
199
+ }
200
+ setCreating(true);
201
+ try {
202
+ const res = await fetch("/api/v1/tasks", {
203
+ method: "POST",
204
+ headers: { "Content-Type": "application/json" },
205
+ body: JSON.stringify({
206
+ title: title.trim(),
207
+ body: body.trim() || undefined,
208
+ executor_kind: executor,
209
+ assignee_user_id: assigneeId || null,
210
+ recurring_schedule:
211
+ executor === "hermes" && schedule.trim() ? schedule.trim() : null,
212
+ // Hermes : envoi immédiat. IA : lancement optionnel du run.
213
+ dispatch: executor === "hermes",
214
+ launch: executor === "ai" && launchNow,
215
+ }),
216
+ });
217
+ const data = (await res.json()) as {
218
+ task?: TaskCard;
219
+ warning?: string | null;
220
+ error?: string;
221
+ };
222
+ if (!res.ok) {
223
+ toast.error(data.error || "Création impossible");
224
+ return;
225
+ }
226
+ if (data.warning) toast.message(data.warning);
227
+ else if (executor === "hermes") toast.success("Tâche envoyée à Hermes");
228
+ else if (executor === "ai" && launchNow) toast.success("Tâche créée — run IA en file");
229
+ else toast.success("Tâche créée");
230
+ setTitle("");
231
+ setBody("");
232
+ setSchedule("");
233
+ setComposerOpen(false);
234
+ await load(false);
235
+ if (data.task?.id) {
236
+ setSelectedId(data.task.id);
237
+ setSheetOpen(true);
238
+ }
239
+ } finally {
240
+ setCreating(false);
241
+ }
242
+ }
243
+
244
+ const setStatus = useCallback(
245
+ async (id: string, status: string) => {
246
+ const res = await fetch(`/api/v1/tasks/${id}`, {
247
+ method: "PATCH",
248
+ headers: { "Content-Type": "application/json" },
249
+ body: JSON.stringify({ status }),
250
+ });
251
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
252
+ if (!res.ok) {
253
+ toast.error(data.error || "Mise à jour impossible");
254
+ return;
255
+ }
256
+ await load(false);
257
+ },
258
+ [load],
259
+ );
260
+
261
+ function onDropTo(col: ColumnKey) {
262
+ setDropCol(null);
263
+ if (!dragId) return;
264
+ const current = Object.values(columns)
265
+ .flat()
266
+ .find((t) => t.id === dragId);
267
+ setDragId(null);
268
+ if (!current || current.status === col) return;
269
+ void setStatus(current.id, col);
270
+ }
271
+
272
+ function openTask(id: string) {
273
+ setSelectedId(id);
274
+ setSheetOpen(true);
275
+ }
276
+
277
+ return (
278
+ <div className="space-y-5">
279
+ {/* Toolbar */}
280
+ <div className="flex flex-col gap-3 rounded-2xl border border-slate-200/80 bg-[linear-gradient(135deg,#f8fafc_0%,#ffffff_45%,#f0f9ff_100%)] p-4 shadow-sm shadow-slate-900/5">
281
+ <div className="flex flex-wrap items-start justify-between gap-3">
282
+ <div className="min-w-0 space-y-1.5">
283
+ <div className="flex flex-wrap items-center gap-2">
284
+ <span className="inline-flex items-center gap-1.5 rounded-full bg-slate-900 px-2.5 py-1 text-[11px] font-medium text-white">
285
+ <Bot className="h-3 w-3" />
286
+ {totalCount} tâche{totalCount !== 1 ? "s" : ""}
287
+ </span>
288
+ <Badge
289
+ variant="outline"
290
+ className={cn(
291
+ "gap-1 border-0 text-[11px]",
292
+ hermesConfigured
293
+ ? "bg-emerald-100 text-emerald-900"
294
+ : "bg-amber-100 text-amber-900",
295
+ )}
296
+ >
297
+ <span
298
+ className={cn(
299
+ "h-1.5 w-1.5 rounded-full",
300
+ hermesConfigured ? "bg-emerald-500" : "bg-amber-500",
301
+ )}
302
+ />
303
+ {hermesConfigured ? "Hermes connecté" : "Hermes hors ligne"}
304
+ </Badge>
305
+ <Badge
306
+ variant="outline"
307
+ className={cn(
308
+ "gap-1 border-0 text-[11px]",
309
+ bridgeReady
310
+ ? "bg-violet-100 text-violet-900"
311
+ : "bg-slate-100 text-slate-500",
312
+ )}
313
+ >
314
+ <span
315
+ className={cn(
316
+ "h-1.5 w-1.5 rounded-full",
317
+ bridgeReady ? "bg-violet-500" : "bg-slate-400",
318
+ )}
319
+ />
320
+ {bridgeReady ? "Workspaces IA prêts" : "Desktop IA hors ligne"}
321
+ </Badge>
322
+ </div>
323
+ <div className="flex flex-wrap items-center gap-1">
324
+ {EXECUTOR_FILTERS.map((f) => (
325
+ <button
326
+ key={f.id}
327
+ type="button"
328
+ onClick={() => setExecutorFilter(f.id)}
329
+ className={cn(
330
+ "rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors",
331
+ executorFilter === f.id
332
+ ? "bg-slate-900 text-white"
333
+ : "bg-white/80 text-slate-600 hover:bg-slate-100",
334
+ )}
335
+ >
336
+ {f.label}
337
+ </button>
338
+ ))}
339
+ </div>
340
+ </div>
341
+ <div className="flex flex-wrap items-center gap-2">
342
+ <div className="relative">
343
+ <Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
344
+ <Input
345
+ value={filter}
346
+ onChange={(e) => setFilter(e.target.value)}
347
+ placeholder="Filtrer…"
348
+ className="h-9 w-44 border-slate-200 bg-white/90 pl-8 text-xs shadow-none"
349
+ />
350
+ </div>
351
+ {hermesConfigured ? (
352
+ <Button
353
+ type="button"
354
+ variant="outline"
355
+ size="sm"
356
+ className="h-9 bg-white/90"
357
+ disabled={syncing || loading}
358
+ onClick={() => void syncNow()}
359
+ >
360
+ {syncing ? (
361
+ <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
362
+ ) : (
363
+ <RefreshCw className="mr-1.5 h-3.5 w-3.5" />
364
+ )}
365
+ Sync Hermes
366
+ </Button>
367
+ ) : null}
368
+ <Button
369
+ type="button"
370
+ size="sm"
371
+ className="h-9"
372
+ onClick={() => setComposerOpen((v) => !v)}
373
+ >
374
+ <Plus className="mr-1.5 h-3.5 w-3.5" />
375
+ Nouvelle tâche
376
+ </Button>
377
+ </div>
378
+ </div>
379
+
380
+ {composerOpen ? (
381
+ <form
382
+ onSubmit={(e) => void createTask(e)}
383
+ className="grid gap-2 rounded-xl border border-slate-200 bg-white/90 p-3 shadow-sm animate-in fade-in slide-in-from-top-1"
384
+ >
385
+ <Input
386
+ placeholder="Titre de la tâche"
387
+ value={title}
388
+ onChange={(e) => setTitle(e.target.value)}
389
+ className="border-slate-200 text-sm font-medium"
390
+ autoFocus
391
+ />
392
+ <textarea
393
+ placeholder="Brief (contexte, contraintes, livrable attendu)…"
394
+ value={body}
395
+ onChange={(e) => setBody(e.target.value)}
396
+ rows={4}
397
+ className="w-full resize-y rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800 outline-none ring-offset-white placeholder:text-slate-400 focus-visible:ring-2 focus-visible:ring-sky-400"
398
+ />
399
+ <div className="flex flex-wrap items-center gap-2">
400
+ <span className="text-[11px] font-medium uppercase tracking-wide text-slate-500">
401
+ Exécutant
402
+ </span>
403
+ {(["human", "ai", "hermes"] as const).map((k) => (
404
+ <button
405
+ key={k}
406
+ type="button"
407
+ onClick={() => setExecutor(k)}
408
+ className={cn(
409
+ "rounded-lg border px-2.5 py-1.5 text-xs font-medium transition-colors",
410
+ executor === k
411
+ ? "border-slate-900 bg-slate-900 text-white"
412
+ : "border-slate-200 bg-white text-slate-600 hover:bg-slate-50",
413
+ )}
414
+ >
415
+ {EXECUTOR_META[k].label}
416
+ </button>
417
+ ))}
418
+ </div>
419
+ {executor !== "hermes" ? (
420
+ <select
421
+ value={assigneeId}
422
+ onChange={(e) => setAssigneeId(e.target.value)}
423
+ className="h-9 rounded-md border border-slate-200 bg-white px-2 text-sm text-slate-800"
424
+ >
425
+ <option value="">
426
+ {executor === "ai"
427
+ ? "— Choisir le collaborateur IA —"
428
+ : "Non assignée"}
429
+ </option>
430
+ {eligibleAssignees.map((a) => (
431
+ <option key={a.id} value={a.id}>
432
+ {a.username}
433
+ </option>
434
+ ))}
435
+ </select>
436
+ ) : (
437
+ <Input
438
+ placeholder="Récurrence optionnelle (every 1d, 0 9 * * 1…)"
439
+ value={schedule}
440
+ onChange={(e) => setSchedule(e.target.value)}
441
+ className="border-slate-200 text-xs"
442
+ />
443
+ )}
444
+ {executor === "ai" ? (
445
+ <label className="flex items-center gap-2 text-xs text-slate-600">
446
+ <input
447
+ type="checkbox"
448
+ checked={launchNow}
449
+ onChange={(e) => setLaunchNow(e.target.checked)}
450
+ className="h-3.5 w-3.5 rounded border-slate-300"
451
+ />
452
+ Lancer le run IA immédiatement
453
+ </label>
454
+ ) : null}
455
+ <div className="flex justify-end gap-2">
456
+ <Button
457
+ type="button"
458
+ variant="ghost"
459
+ size="sm"
460
+ onClick={() => setComposerOpen(false)}
461
+ >
462
+ Annuler
463
+ </Button>
464
+ <Button type="submit" size="sm" disabled={creating || !title.trim()}>
465
+ {creating ? (
466
+ <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
467
+ ) : (
468
+ <Sparkles className="mr-1.5 h-3.5 w-3.5" />
469
+ )}
470
+ {executor === "hermes"
471
+ ? "Créer & envoyer à Hermes"
472
+ : executor === "ai" && launchNow
473
+ ? "Créer & lancer"
474
+ : "Créer"}
475
+ </Button>
476
+ </div>
477
+ </form>
478
+ ) : null}
479
+ </div>
480
+
481
+ {/* Board */}
482
+ {loading && totalCount === 0 ? (
483
+ <div className="flex items-center gap-2 py-16 text-sm text-slate-500">
484
+ <Loader2 className="h-4 w-4 animate-spin" /> Chargement du board…
485
+ </div>
486
+ ) : (
487
+ <div className="-mx-1 flex gap-3 overflow-x-auto px-1 pb-2 md:grid md:grid-cols-4 md:overflow-visible">
488
+ {KANBAN_COLUMNS.map((col) => {
489
+ const meta = STATUS_META[col.key];
490
+ const items = filteredColumns[col.key] || [];
491
+ return (
492
+ <section
493
+ key={col.key}
494
+ onDragOver={(e) => {
495
+ e.preventDefault();
496
+ setDropCol(col.key);
497
+ }}
498
+ onDragLeave={() =>
499
+ setDropCol((c) => (c === col.key ? null : c))
500
+ }
501
+ onDrop={(e) => {
502
+ e.preventDefault();
503
+ onDropTo(col.key);
504
+ }}
505
+ className={cn(
506
+ "flex w-[270px] shrink-0 flex-col rounded-2xl border bg-gradient-to-b shadow-sm transition-colors md:w-auto",
507
+ meta?.colTint || "from-slate-50 to-white",
508
+ dropCol === col.key && dragId
509
+ ? "border-sky-400 ring-2 ring-sky-200"
510
+ : "border-slate-200/80",
511
+ )}
512
+ >
513
+ <header className="flex items-center justify-between px-3 py-2.5">
514
+ <div className="flex items-center gap-2">
515
+ <span
516
+ className={cn(
517
+ "h-2 w-2 rounded-full",
518
+ meta?.barClass || "bg-slate-400",
519
+ )}
520
+ />
521
+ <h2 className="text-[11px] font-semibold uppercase tracking-[0.08em] text-slate-600">
522
+ {col.label}
523
+ </h2>
524
+ </div>
525
+ <span className="rounded-full bg-white/80 px-2 py-0.5 text-[11px] font-medium text-slate-500 shadow-sm">
526
+ {items.length}
527
+ </span>
528
+ </header>
529
+
530
+ <ul className="flex min-h-[320px] flex-1 flex-col gap-2 px-2 pb-3">
531
+ {items.length === 0 ? (
532
+ <li className="flex flex-1 items-center justify-center rounded-xl border border-dashed border-slate-200/80 px-3 py-8 text-center text-[11px] text-slate-400">
533
+ {dragId ? "Déposer ici" : "Vide"}
534
+ </li>
535
+ ) : (
536
+ items.map((t) => {
537
+ const preview = previewBrief(t.body);
538
+ const exec = EXECUTOR_META[t.executor_kind];
539
+ const running =
540
+ t.executor_kind !== "human" &&
541
+ t.status === "in_progress";
542
+ return (
543
+ <li key={t.id}>
544
+ <button
545
+ type="button"
546
+ draggable
547
+ onDragStart={() => setDragId(t.id)}
548
+ onDragEnd={() => {
549
+ setDragId(null);
550
+ setDropCol(null);
551
+ }}
552
+ onClick={() => openTask(t.id)}
553
+ className={cn(
554
+ "group w-full cursor-grab rounded-xl border border-slate-200/90 bg-white p-3 text-left shadow-sm transition active:cursor-grabbing",
555
+ "hover:-translate-y-0.5 hover:border-sky-300 hover:shadow-md hover:shadow-sky-900/5",
556
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400",
557
+ dragId === t.id && "opacity-50",
558
+ selectedId === t.id && sheetOpen
559
+ ? "border-sky-400 ring-2 ring-sky-200"
560
+ : null,
561
+ )}
562
+ >
563
+ <div
564
+ className={cn(
565
+ "mb-2 h-0.5 w-10 rounded-full",
566
+ meta?.barClass || "bg-slate-300",
567
+ )}
568
+ />
569
+ <div className="flex items-start justify-between gap-2">
570
+ <p className="text-sm font-semibold leading-snug text-slate-900">
571
+ {t.title}
572
+ </p>
573
+ <ChevronRight className="mt-0.5 h-4 w-4 shrink-0 text-slate-300 transition group-hover:translate-x-0.5 group-hover:text-sky-500" />
574
+ </div>
575
+ {preview ? (
576
+ <p className="mt-1.5 line-clamp-2 text-[11px] leading-relaxed text-slate-600">
577
+ {preview}
578
+ </p>
579
+ ) : null}
580
+
581
+ <div className="mt-2.5 flex flex-wrap items-center gap-1">
582
+ <span
583
+ className={cn(
584
+ "inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium",
585
+ exec.badgeClass,
586
+ )}
587
+ >
588
+ {t.executor_kind === "human" ? (
589
+ <User className="h-3 w-3" />
590
+ ) : (
591
+ <Bot className="h-3 w-3" />
592
+ )}
593
+ {t.assignee?.username || exec.short}
594
+ {running ? (
595
+ <Loader2 className="h-3 w-3 animate-spin" />
596
+ ) : null}
597
+ </span>
598
+ {t.result ? (
599
+ <span className="inline-flex items-center gap-1 rounded-md bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-800">
600
+ <CheckCircle2 className="h-3 w-3" />
601
+ Rapport
602
+ </span>
603
+ ) : null}
604
+ {t.recurring_schedule ? (
605
+ <span className="inline-flex items-center gap-1 rounded-md bg-violet-50 px-1.5 py-0.5 text-[10px] font-medium text-violet-800">
606
+ <Repeat2 className="h-3 w-3" />
607
+ Récurrente
608
+ </span>
609
+ ) : null}
610
+ <span className="rounded-md bg-slate-50 px-1.5 py-0.5 text-[10px] text-slate-500">
611
+ {sourceLabel(t.source)}
612
+ </span>
613
+ </div>
614
+
615
+ <div className="mt-2 flex items-center justify-between text-[10px] text-slate-400">
616
+ <span>
617
+ {formatDistanceToNow(new Date(t.updated_at), {
618
+ addSuffix: true,
619
+ locale: fr,
620
+ })}
621
+ </span>
622
+ <span className="font-medium text-sky-600 opacity-0 transition group-hover:opacity-100">
623
+ Voir le détail
624
+ </span>
625
+ </div>
626
+ </button>
627
+ </li>
628
+ );
629
+ })
630
+ )}
631
+ </ul>
632
+ </section>
633
+ );
634
+ })}
635
+ </div>
636
+ )}
637
+
638
+ <TaskDetailSheet
639
+ taskId={selectedId}
640
+ open={sheetOpen}
641
+ onOpenChange={setSheetOpen}
642
+ onStatusChange={setStatus}
643
+ onChanged={() => void load(false)}
644
+ />
645
+ </div>
646
+ );
647
+ }