jettypod 4.4.118 → 4.4.121

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 (240) hide show
  1. package/.env +4 -3
  2. package/Cargo.lock +6450 -0
  3. package/Cargo.toml +35 -0
  4. package/README.md +5 -1
  5. package/TAURI-MIGRATION-PLAN.md +840 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +5 -6
  7. package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
  8. package/apps/dashboard/app/demo/gates/page.tsx +43 -45
  9. package/apps/dashboard/app/design-system/page.tsx +868 -0
  10. package/apps/dashboard/app/globals.css +80 -4
  11. package/apps/dashboard/app/install-claude/page.tsx +4 -6
  12. package/apps/dashboard/app/login/page.tsx +72 -54
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +61 -13
  15. package/apps/dashboard/app/signup/page.tsx +242 -0
  16. package/apps/dashboard/app/subscribe/page.tsx +0 -2
  17. package/apps/dashboard/app/tests/page.tsx +37 -4
  18. package/apps/dashboard/app/welcome/page.tsx +13 -16
  19. package/apps/dashboard/app/work/[id]/page.tsx +117 -118
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +92 -85
  22. package/apps/dashboard/components/CardMenu.tsx +45 -12
  23. package/apps/dashboard/components/ClaudePanel.tsx +771 -850
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
  26. package/apps/dashboard/components/CopyableId.tsx +3 -4
  27. package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
  28. package/apps/dashboard/components/DragContext.tsx +134 -63
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +6 -7
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +26 -7
  34. package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
  35. package/apps/dashboard/components/EpicGroup.tsx +359 -0
  36. package/apps/dashboard/components/GateCard.tsx +79 -17
  37. package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
  39. package/apps/dashboard/components/JettyLoader.tsx +37 -0
  40. package/apps/dashboard/components/KanbanBoard.tsx +368 -958
  41. package/apps/dashboard/components/KanbanCard.tsx +740 -0
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
  44. package/apps/dashboard/components/MainNav.tsx +38 -73
  45. package/apps/dashboard/components/MessageBlock.tsx +468 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -16
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
  53. package/apps/dashboard/components/ReviewFooter.tsx +139 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -19
  55. package/apps/dashboard/components/SubscribeContent.tsx +91 -47
  56. package/apps/dashboard/components/TestTree.tsx +16 -16
  57. package/apps/dashboard/components/TipCard.tsx +16 -17
  58. package/apps/dashboard/components/Toast.tsx +5 -6
  59. package/apps/dashboard/components/TypeIcon.tsx +55 -0
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
  62. package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
  64. package/apps/dashboard/components/WorkItemTree.tsx +11 -32
  65. package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
  66. package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
  67. package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
  68. package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
  72. package/apps/dashboard/components/ui/Button.tsx +104 -0
  73. package/apps/dashboard/components/ui/Input.tsx +78 -0
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
  77. package/apps/dashboard/contexts/UsageContext.tsx +87 -32
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  83. package/apps/dashboard/index.html +73 -0
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/data-bridge.ts +722 -0
  86. package/apps/dashboard/lib/db.ts +69 -1265
  87. package/apps/dashboard/lib/environment-config.ts +173 -0
  88. package/apps/dashboard/lib/environment-verification.ts +119 -0
  89. package/apps/dashboard/lib/kanban-utils.ts +270 -0
  90. package/apps/dashboard/lib/proof-run.ts +495 -0
  91. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  92. package/apps/dashboard/lib/run-migrations.js +27 -2
  93. package/apps/dashboard/lib/service-recovery.ts +326 -0
  94. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  95. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  96. package/apps/dashboard/lib/session-stream-manager.ts +308 -134
  97. package/apps/dashboard/lib/shadows.ts +7 -0
  98. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  99. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  100. package/apps/dashboard/lib/tauri.ts +106 -0
  101. package/apps/dashboard/lib/utils.ts +6 -0
  102. package/apps/dashboard/next-env.d.ts +1 -1
  103. package/apps/dashboard/package.json +21 -32
  104. package/apps/dashboard/public/bug-icon.png +0 -0
  105. package/apps/dashboard/public/buoy-icon.png +0 -0
  106. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  107. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  108. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  109. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  110. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  111. package/apps/dashboard/public/jettypod_logo.png +0 -0
  112. package/apps/dashboard/public/pier-icon.png +0 -0
  113. package/apps/dashboard/public/star-icon.png +0 -0
  114. package/apps/dashboard/public/wrench-icon.png +0 -0
  115. package/apps/dashboard/scripts/tauri-build.js +228 -0
  116. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  117. package/apps/dashboard/scripts/ws-server.js +191 -0
  118. package/apps/dashboard/src/main.tsx +12 -0
  119. package/apps/dashboard/src/router.tsx +107 -0
  120. package/apps/dashboard/src/vite-env.d.ts +1 -0
  121. package/apps/dashboard/tsconfig.json +7 -12
  122. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  123. package/apps/dashboard/vite.config.ts +33 -0
  124. package/apps/update-server/src/index.ts +228 -80
  125. package/claude-hooks/global-guardrails.js +14 -13
  126. package/crates/jettypod-cli/Cargo.toml +19 -0
  127. package/crates/jettypod-cli/src/commands.rs +1249 -0
  128. package/crates/jettypod-cli/src/main.rs +595 -0
  129. package/crates/jettypod-core/Cargo.toml +26 -0
  130. package/crates/jettypod-core/build.rs +98 -0
  131. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  132. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  133. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  134. package/crates/jettypod-core/src/auth.rs +294 -0
  135. package/crates/jettypod-core/src/config.rs +397 -0
  136. package/crates/jettypod-core/src/db/mod.rs +507 -0
  137. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  138. package/crates/jettypod-core/src/db/startup.rs +101 -0
  139. package/crates/jettypod-core/src/db/validate.rs +149 -0
  140. package/crates/jettypod-core/src/error.rs +76 -0
  141. package/crates/jettypod-core/src/git.rs +458 -0
  142. package/crates/jettypod-core/src/lib.rs +20 -0
  143. package/crates/jettypod-core/src/sessions.rs +625 -0
  144. package/crates/jettypod-core/src/skills.rs +556 -0
  145. package/crates/jettypod-core/src/work.rs +1086 -0
  146. package/crates/jettypod-core/src/worktree.rs +628 -0
  147. package/crates/jettypod-core/src/ws.rs +767 -0
  148. package/cucumber-test.cjs +6 -0
  149. package/cucumber.js +9 -3
  150. package/docs/COMMAND_REFERENCE.md +34 -0
  151. package/hooks/post-checkout +32 -75
  152. package/hooks/post-merge +111 -10
  153. package/jest.setup.js +1 -0
  154. package/jettypod.js +145 -116
  155. package/lib/bdd-preflight.js +96 -0
  156. package/lib/chore-taxonomy.js +33 -10
  157. package/lib/database.js +36 -16
  158. package/lib/db-watcher.js +1 -1
  159. package/lib/git-hooks/pre-commit +1 -1
  160. package/lib/jettypod-backup.js +27 -4
  161. package/lib/merge-lock.js +111 -253
  162. package/lib/migrations/027-plan-at-creation-column.js +3 -1
  163. package/lib/migrations/029-remove-autoincrement.js +307 -0
  164. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  165. package/lib/migrations/030-rejection-round-columns.js +54 -0
  166. package/lib/migrations/031-session-isolation-index.js +17 -0
  167. package/lib/migrations/index.js +47 -4
  168. package/lib/schema.js +10 -5
  169. package/lib/seed-onboarding.js +1 -1
  170. package/lib/update-command/index.js +9 -175
  171. package/lib/work-commands/index.js +144 -19
  172. package/lib/work-tracking/index.js +148 -27
  173. package/lib/worktree-diagnostics.js +16 -16
  174. package/lib/worktree-facade.js +1 -1
  175. package/lib/worktree-manager.js +8 -8
  176. package/lib/worktree-reconciler.js +5 -5
  177. package/package.json +9 -2
  178. package/scripts/ndjson-to-cucumber-json.js +152 -0
  179. package/scripts/postinstall.js +25 -0
  180. package/skills-templates/bug-mode/SKILL.md +79 -20
  181. package/skills-templates/bug-planning/SKILL.md +25 -29
  182. package/skills-templates/chore-mode/SKILL.md +171 -69
  183. package/skills-templates/chore-mode/verification.js +51 -10
  184. package/skills-templates/chore-planning/SKILL.md +47 -18
  185. package/skills-templates/design-system-selection/SKILL.md +273 -0
  186. package/skills-templates/epic-planning/SKILL.md +82 -48
  187. package/skills-templates/external-transition/SKILL.md +47 -47
  188. package/skills-templates/feature-planning/SKILL.md +173 -74
  189. package/skills-templates/production-mode/SKILL.md +69 -49
  190. package/skills-templates/request-routing/SKILL.md +4 -4
  191. package/skills-templates/simple-improvement/SKILL.md +74 -29
  192. package/skills-templates/speed-mode/SKILL.md +217 -141
  193. package/skills-templates/stable-mode/SKILL.md +148 -89
  194. package/apps/dashboard/README.md +0 -36
  195. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
  196. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  197. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
  198. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  199. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
  200. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  201. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  202. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  203. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  204. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  205. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  206. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  207. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  208. package/apps/dashboard/app/api/tests/route.ts +0 -9
  209. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  210. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  211. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  212. package/apps/dashboard/app/api/usage/route.ts +0 -17
  213. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  214. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  215. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  216. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
  217. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  218. package/apps/dashboard/app/layout.tsx +0 -43
  219. package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
  220. package/apps/dashboard/electron/ipc-handlers.js +0 -1028
  221. package/apps/dashboard/electron/main.js +0 -2124
  222. package/apps/dashboard/electron/preload.js +0 -123
  223. package/apps/dashboard/electron/session-manager.js +0 -141
  224. package/apps/dashboard/electron-builder.config.js +0 -357
  225. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  226. package/apps/dashboard/lib/claude-process-manager.ts +0 -492
  227. package/apps/dashboard/lib/db-bridge.ts +0 -282
  228. package/apps/dashboard/lib/prototypes.ts +0 -202
  229. package/apps/dashboard/lib/test-results-db.ts +0 -307
  230. package/apps/dashboard/lib/tests.ts +0 -282
  231. package/apps/dashboard/next.config.js +0 -50
  232. package/apps/dashboard/postcss.config.mjs +0 -7
  233. package/apps/dashboard/public/file.svg +0 -1
  234. package/apps/dashboard/public/globe.svg +0 -1
  235. package/apps/dashboard/public/next.svg +0 -1
  236. package/apps/dashboard/public/vercel.svg +0 -1
  237. package/apps/dashboard/public/window.svg +0 -1
  238. package/apps/dashboard/scripts/download-node.js +0 -104
  239. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
  240. package/docs/bdd-guidance.md +0 -390
@@ -0,0 +1,740 @@
1
+
2
+ import { useState, useRef, useCallback, memo } from 'react';
3
+ import { Link, useNavigate } from 'react-router-dom';
4
+ import type { WorkItem } from '@/lib/db';
5
+ import { prefetch } from '@/lib/data-bridge';
6
+ import { EditableTitle } from './EditableTitle';
7
+ import { CardMenu } from './CardMenu';
8
+ import { CopyableId } from './CopyableId';
9
+ import { WaveCompletionAnimation } from './WaveCompletionAnimation';
10
+ import { Input } from '@/components/ui/Input';
11
+ import { Button } from '@/components/ui/Button';
12
+ import { MODE_LABELS } from '@/lib/constants';
13
+ import { TypeIcon } from './TypeIcon';
14
+ import { shadow } from '@/lib/shadows';
15
+
16
+ function formatRejectionTime(isoString: string): string {
17
+ try {
18
+ const date = new Date(isoString);
19
+ const now = new Date();
20
+ const diffMs = now.getTime() - date.getTime();
21
+ const diffMins = Math.floor(diffMs / 60000);
22
+ const diffHours = Math.floor(diffMs / 3600000);
23
+ const diffDays = Math.floor(diffMs / 86400000);
24
+
25
+ if (diffMins < 1) return 'just now';
26
+ if (diffMins < 60) return `${diffMins}m ago`;
27
+ if (diffHours < 24) return `${diffHours}h ago`;
28
+ if (diffDays === 1) return 'yesterday';
29
+ if (diffDays < 7) return `${diffDays}d ago`;
30
+ return date.toLocaleDateString();
31
+ } catch {
32
+ return '';
33
+ }
34
+ }
35
+
36
+ function getModeLabel(item: WorkItem): string {
37
+ if (!item.mode) return '';
38
+ const base = MODE_LABELS[item.mode]?.label || item.mode;
39
+ if (item.current_step && item.total_steps) {
40
+ return `${base} ${item.current_step}/${item.total_steps}`;
41
+ }
42
+ return base;
43
+ }
44
+
45
+ // Session indicator icon - shows when item has an active Claude session
46
+ function SessionIndicatorIcon({ className }: { className?: string }) {
47
+ return (
48
+ <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
49
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
50
+ </svg>
51
+ );
52
+ }
53
+
54
+ interface RejectionHistoryEntry {
55
+ round: number;
56
+ reason: string;
57
+ at: string;
58
+ }
59
+
60
+ function RejectionTimeline({ item, allChores, allBugs, isDone }: { item: WorkItem; allChores: WorkItem[]; allBugs: WorkItem[]; isDone: boolean }) {
61
+ let history: RejectionHistoryEntry[] = [];
62
+ try {
63
+ history = item.rejection_history ? JSON.parse(item.rejection_history) : [];
64
+ } catch {
65
+ // Corrupt JSON — degrade gracefully, show segments without reasons
66
+ }
67
+ const allChildren = [...allChores, ...allBugs];
68
+
69
+ // Build timeline segments
70
+ const segments: Array<{ label: string; round: number | null; reason: string | null; isLatest: boolean; children: WorkItem[] }> = [];
71
+
72
+ // Original segment (children with null rejection_round)
73
+ const originalChildren = allChildren.filter(c => c.rejection_round == null);
74
+ if (originalChildren.length > 0) {
75
+ segments.push({ label: 'Original', round: null, reason: null, isLatest: false, children: originalChildren });
76
+ }
77
+
78
+ // Rejection round segments
79
+ for (let r = 1; r <= (item.rejection_count || 0); r++) {
80
+ const roundChildren = allChildren.filter(c => c.rejection_round === r);
81
+ const entry = history.find(h => h.round === r);
82
+ segments.push({
83
+ label: `Rejection #${r}`,
84
+ round: r,
85
+ reason: entry?.reason || null,
86
+ isLatest: r === item.rejection_count,
87
+ children: roundChildren,
88
+ });
89
+ }
90
+
91
+ return (
92
+ <div className="relative pl-[18px]">
93
+ {segments.map((seg, idx) => {
94
+ const isLast = idx === segments.length - 1;
95
+ return (
96
+ <div key={seg.label} className="relative" style={{ paddingBottom: isLast ? 0 : 4 }}>
97
+ {/* Vertical connector line */}
98
+ {!isLast && (
99
+ <div
100
+ className="absolute border-l-2 border-zinc-200 dark:border-zinc-700"
101
+ style={{ left: -12, top: 14, bottom: -4 }}
102
+ />
103
+ )}
104
+ {/* Node marker + label */}
105
+ <div className="relative flex items-center gap-2 py-1">
106
+ <div
107
+ className="absolute rounded-full"
108
+ style={{
109
+ left: -16, top: '50%', transform: 'translateY(-50%)',
110
+ width: 10, height: 10,
111
+ background: seg.round === null ? '#819D9F' : '#ef4444',
112
+ border: '2px solid var(--card, #27272a)',
113
+ zIndex: 1,
114
+ ...(seg.isLatest ? { boxShadow: '0 0 0 3px rgba(239, 68, 68, 0.2)' } : {}),
115
+ }}
116
+ />
117
+ <span className={`text-[11px] font-semibold uppercase tracking-wide ${
118
+ seg.round === null ? 'text-[#819D9F]' : 'text-red-400 dark:text-red-400'
119
+ }`}>{seg.label}</span>
120
+ {seg.reason && (
121
+ <span className="text-[11px] italic text-zinc-500 dark:text-zinc-500 truncate max-w-[180px]">
122
+ {seg.reason}
123
+ </span>
124
+ )}
125
+ </div>
126
+ {/* Children under this segment */}
127
+ <div className="py-0.5">
128
+ {seg.children.map((child) => {
129
+ const isComplete = child.status === 'done';
130
+ const isBug = child.type === 'bug';
131
+ return (
132
+ <Link
133
+ key={child.id}
134
+ to={`/work/${child.id}`}
135
+ viewTransition
136
+ className={`block py-1.5 px-2.5 text-base rounded transition-colors duration-200 ease-out ${
137
+ isComplete
138
+ ? 'bg-zinc-100 dark:bg-zinc-800/50'
139
+ : 'hover:bg-zinc-100 dark:hover:bg-zinc-700'
140
+ }`}
141
+ >
142
+ <div className="flex items-center gap-3">
143
+ <span className={`font-mono text-xs ${isComplete ? 'text-zinc-500' : 'text-zinc-400'}`}>#{child.id}</span>
144
+ {isBug && <TypeIcon type="bug" />}
145
+ {!isDone && !isBug && child.mode && MODE_LABELS[child.mode] && (
146
+ <span className={`px-1 py-0.5 rounded text-[10px] ${MODE_LABELS[child.mode].color}`}>
147
+ {getModeLabel(child)}
148
+ </span>
149
+ )}
150
+ <span className={`truncate ${
151
+ isComplete ? 'text-zinc-500' : 'text-zinc-700 dark:text-zinc-300'
152
+ }`}>
153
+ {child.title || <span className="text-zinc-400 italic">(Untitled)</span>}
154
+ </span>
155
+ </div>
156
+ </Link>
157
+ );
158
+ })}
159
+ {seg.children.length === 0 && (
160
+ <span className="text-xs text-zinc-500 italic pl-2.5">No items</span>
161
+ )}
162
+ </div>
163
+ </div>
164
+ );
165
+ })}
166
+ </div>
167
+ );
168
+ }
169
+
170
+ export interface KanbanCardProps {
171
+ item: WorkItem;
172
+ epicTitle?: string | null;
173
+ showEpic?: boolean;
174
+ isInFlight?: boolean;
175
+ onTitleSave?: (id: number, newTitle: string) => Promise<void>;
176
+ onStatusChange?: (id: number, newStatus: string) => Promise<void | { success: boolean; notFound?: boolean }>;
177
+ onReject?: (id: number, reason: string) => Promise<void>;
178
+ onTriggerClaude?: (id: number, title: string, type: string, conversational?: boolean, description?: string | null) => void;
179
+ hasActiveSession?: boolean;
180
+ onOpenSession?: (id: string) => void;
181
+ onCloseSession?: (id: string) => void;
182
+ onRestart?: (id: number) => void;
183
+ usageAllowed?: boolean;
184
+ // Animation state lifted to board level
185
+ isCompletingAnimation?: boolean;
186
+ onAnimationComplete?: () => void;
187
+ isHighlighted?: boolean;
188
+ }
189
+
190
+ export const KanbanCard = memo(function KanbanCard({ item, epicTitle, showEpic = false, isInFlight = false, onTitleSave, onStatusChange, onReject, onTriggerClaude, hasActiveSession, onOpenSession, onCloseSession, onRestart, usageAllowed = true, isCompletingAnimation = false, onAnimationComplete, isHighlighted = false }: KanbanCardProps) {
191
+ const [expanded, setExpanded] = useState(false);
192
+ const [showRejectInput, setShowRejectInput] = useState(false);
193
+ const [rejectReason, setRejectReason] = useState('');
194
+ const [isEditingTitle, setIsEditingTitle] = useState(false);
195
+ const [rejectionExpanded, setRejectionExpanded] = useState(false);
196
+ const navigate = useNavigate();
197
+
198
+ const handleOpenSession = (e: React.MouseEvent) => {
199
+ e.stopPropagation(); // Prevent card navigation
200
+ if (onOpenSession) {
201
+ onOpenSession(String(item.id));
202
+ }
203
+ };
204
+
205
+ const handleStart = async (e: React.MouseEvent) => {
206
+ e.stopPropagation(); // Prevent card navigation
207
+ if (onStatusChange) {
208
+ await onStatusChange(item.id, 'in_progress');
209
+ if (onTriggerClaude) {
210
+ onTriggerClaude(item.id, item.title, item.type, !!item.conversational, item.description);
211
+ }
212
+ }
213
+ };
214
+
215
+ const handleRestart = (e: React.MouseEvent) => {
216
+ e.stopPropagation();
217
+ if (onRestart) {
218
+ onRestart(item.id);
219
+ }
220
+ };
221
+
222
+ const canStart = item.status === 'backlog' || item.status === 'cancelled';
223
+
224
+ // An item is reviewable when it has ready_for_review flag set
225
+ // This applies to kanban-visible items: features, standalone chores/bugs, and items under epics
226
+ const isReviewable = !!item.ready_for_review;
227
+
228
+ const handleAccept = async (e: React.MouseEvent) => {
229
+ e.stopPropagation();
230
+ if (onStatusChange) {
231
+ await onStatusChange(item.id, 'done');
232
+ }
233
+ if (onCloseSession) {
234
+ onCloseSession(String(item.id));
235
+ }
236
+ };
237
+
238
+ const handleRejectClick = (e: React.MouseEvent) => {
239
+ e.stopPropagation();
240
+ setShowRejectInput(true);
241
+ };
242
+
243
+ const handleRejectConfirm = async (e: React.MouseEvent) => {
244
+ e.stopPropagation();
245
+ if (onReject && rejectReason.trim()) {
246
+ await onReject(item.id, rejectReason.trim());
247
+ setShowRejectInput(false);
248
+ setRejectReason('');
249
+ }
250
+ };
251
+
252
+ const handleRejectCancel = (e: React.MouseEvent) => {
253
+ e.stopPropagation();
254
+ setShowRejectInput(false);
255
+ setRejectReason('');
256
+ };
257
+
258
+ // Calculate chores for expandable section
259
+ const allChores = item.chores || [];
260
+ const incompleteChores = allChores.filter(c => c.status !== 'done');
261
+ const hasChores = allChores.length > 0;
262
+ const hasIncompleteChores = incompleteChores.length > 0;
263
+
264
+ // Calculate bugs for expandable section
265
+ const allBugs = item.bugs || [];
266
+ const incompleteBugs = allBugs.filter(b => b.status !== 'done');
267
+ const hasBugs = allBugs.length > 0;
268
+
269
+ const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
270
+
271
+ const handleCardClick = () => {
272
+ navigate(`/work/${item.id}`);
273
+ };
274
+
275
+ const handleMouseEnter = useCallback(() => {
276
+ hoverTimer.current = setTimeout(() => {
277
+ prefetch.workItem(item.id);
278
+ }, 100);
279
+ }, [item.id]);
280
+
281
+ const handleMouseLeave = useCallback(() => {
282
+ if (hoverTimer.current) {
283
+ clearTimeout(hoverTimer.current);
284
+ hoverTimer.current = null;
285
+ }
286
+ }, []);
287
+
288
+ const handleTitleSave = async (id: number, newTitle: string) => {
289
+ if (onTitleSave) {
290
+ await onTitleSave(id, newTitle);
291
+ }
292
+ };
293
+
294
+ // Status changes are now handled by the board-level wrapper that triggers animation
295
+ const handleStatusChange = async (id: number, newStatus: string) => {
296
+ if (onStatusChange) {
297
+ await onStatusChange(id, newStatus);
298
+ }
299
+ };
300
+
301
+ const isDone = item.status === 'done';
302
+
303
+ const cardStyles = {
304
+ className: 'bg-white dark:bg-zinc-800',
305
+ boxShadow: shadow.sm,
306
+ hoverBoxShadow: shadow.lg,
307
+ };
308
+
309
+ const cardContent = (
310
+ <WaveCompletionAnimation isPlaying={isCompletingAnimation} onComplete={onAnimationComplete || (() => {})}>
311
+ <div
312
+ className={`kanban-card rounded-xl overflow-hidden transition-[background-color,opacity] duration-200 ease-out ${cardStyles.className}`}
313
+ style={{ boxShadow: cardStyles.boxShadow, '--hover-shadow': cardStyles.hoverBoxShadow } as React.CSSProperties}
314
+ data-testid={`kanban-card-${item.id}`}>
315
+ <div
316
+ onClick={handleCardClick}
317
+ onMouseEnter={handleMouseEnter}
318
+ onMouseLeave={handleMouseLeave}
319
+ className={`block ${isDone ? 'pt-4 px-4 pb-3' : 'p-4'} cursor-pointer`}
320
+ >
321
+ <div className="flex items-start gap-3">
322
+ <span className="text-base flex-shrink-0"><TypeIcon type={item.type} /></span>
323
+ <div className="flex-1 min-w-0">
324
+ {isDone ? (
325
+ /* Compact layout for done cards: ID and title inline, no mode badge */
326
+ <div className="flex items-start gap-3">
327
+ <CopyableId id={item.id} title={item.title} type={item.type} />
328
+ {hasActiveSession && (
329
+ <button
330
+ onClick={handleOpenSession}
331
+ className="p-1 rounded hover:bg-zinc-100 dark:hover:bg-zinc-700 text-[#819D9F] transition-colors duration-200 ease-out"
332
+ aria-label="Open active session"
333
+ data-testid={`session-indicator-${item.id}`}
334
+ title="Open session"
335
+ >
336
+ <SessionIndicatorIcon className="w-4 h-4" />
337
+ </button>
338
+ )}
339
+ <span className="text-base font-medium leading-snug text-zinc-900 dark:text-zinc-100">
340
+ {item.title || <span className="text-zinc-400 italic">(Untitled)</span>}
341
+ </span>
342
+ </div>
343
+ ) : (
344
+ /* Standard layout: ID + mode badge on line 1, title below */
345
+ <>
346
+ <div className="flex items-start justify-between mb-1.5">
347
+ <div className="flex items-center gap-3 flex-wrap">
348
+ <CopyableId id={item.id} title={item.title} type={item.type} />
349
+ {hasActiveSession && (
350
+ <button
351
+ onClick={handleOpenSession}
352
+ className="p-1 rounded hover:bg-zinc-100 dark:hover:bg-zinc-700 text-[#819D9F] transition-colors duration-200 ease-out"
353
+ aria-label="Open active session"
354
+ data-testid={`session-indicator-${item.id}`}
355
+ title="Open session"
356
+ >
357
+ <SessionIndicatorIcon className="w-4 h-4" />
358
+ </button>
359
+ )}
360
+ {item.mode && MODE_LABELS[item.mode] && (
361
+ <span className={`text-xs px-2 py-1 rounded ${MODE_LABELS[item.mode].color}`}>
362
+ {getModeLabel(item)}
363
+ </span>
364
+ )}
365
+ </div>
366
+ {isReviewable ? (
367
+ <div className="flex items-center gap-1.5 flex-shrink-0">
368
+ {item.type === 'feature' && (
369
+ <Button
370
+ onClick={(e: React.MouseEvent) => {
371
+ e.stopPropagation();
372
+ navigate(`/work/${item.id}/proof`);
373
+ }}
374
+ variant="secondary"
375
+ size="xs"
376
+ aria-label="Launch QA proof dashboard"
377
+ data-testid={`qa-button-${item.id}`}
378
+ >
379
+ QA
380
+ </Button>
381
+ )}
382
+ {item.status !== 'done' && onStatusChange && (
383
+ <Button
384
+ onClick={handleAccept}
385
+ variant="secondary"
386
+ size="xs"
387
+ aria-label="Accept work item"
388
+ data-testid={`accept-button-${item.id}`}
389
+ >
390
+ Accept
391
+ </Button>
392
+ )}
393
+ {onReject && (
394
+ <Button
395
+ onClick={handleRejectClick}
396
+ variant="secondary"
397
+ size="xs"
398
+ aria-label="Reject work item"
399
+ data-testid={`reject-button-${item.id}`}
400
+ >
401
+ Reject
402
+ </Button>
403
+ )}
404
+ </div>
405
+ ) : item.rejection_reason && !isDone ? (
406
+ <div className="flex items-center gap-1.5 flex-shrink-0">
407
+ {!hasActiveSession && onRestart && (
408
+ <Button
409
+ onClick={handleRestart}
410
+ variant="secondary"
411
+ size="xs"
412
+ aria-label="Restart work on rejected item"
413
+ data-testid={`restart-button-${item.id}`}
414
+ >
415
+ restart
416
+ </Button>
417
+ )}
418
+ </div>
419
+ ) : null}
420
+ {canStart && onStatusChange && (
421
+ <Button
422
+ onClick={handleStart}
423
+ variant="secondary"
424
+ size="xs"
425
+ aria-label="Start work"
426
+ data-testid={`start-button-${item.id}`}
427
+ >
428
+ Start
429
+ </Button>
430
+ )}
431
+ </div>
432
+ <EditableTitle
433
+ title={item.title}
434
+ itemId={item.id}
435
+ onSave={handleTitleSave}
436
+ clickToEdit={false}
437
+ isEditing={isEditingTitle}
438
+ onEditingChange={setIsEditingTitle}
439
+ />
440
+ </>
441
+ )}
442
+ {showEpic && epicTitle && (() => {
443
+ const epicId = item.parent_id || item.epic_id;
444
+ return epicId ? (
445
+ <Link to={`/work/${epicId}`}
446
+ viewTransition
447
+ onClick={(e) => e.stopPropagation()}
448
+ className="group/epic text-sm text-zinc-400 dark:text-zinc-500 mt-2 flex items-center gap-1.5 hover:text-zinc-600 dark:hover:text-zinc-300"
449
+ >
450
+ <TypeIcon type="epic" className="w-5 h-5 inline" />
451
+ <span className="group-hover/epic:underline">{epicTitle}</span>
452
+ </Link>
453
+ ) : (
454
+ <p className="text-sm text-zinc-400 dark:text-zinc-500 mt-2 flex items-center gap-1.5">
455
+ <TypeIcon type="epic" className="w-5 h-5 inline" />
456
+ <span>{epicTitle}</span>
457
+ </p>
458
+ );
459
+ })()}
460
+ </div>
461
+ <div className="flex items-center gap-1.5">
462
+ {onStatusChange && (
463
+ <CardMenu
464
+ itemId={item.id}
465
+ itemTitle={item.title}
466
+ itemType={item.type}
467
+ itemDescription={item.description}
468
+ conversational={!!item.conversational}
469
+ currentStatus={item.status}
470
+ onStatusChange={handleStatusChange}
471
+ onTriggerClaude={onTriggerClaude}
472
+ hasActiveSession={hasActiveSession}
473
+ onOpenSession={onOpenSession}
474
+ usageAllowed={usageAllowed}
475
+ onEditName={() => setIsEditingTitle(true)}
476
+ onUnaccept={item.status === 'done' && onReject ? () => setShowRejectInput(true) : undefined}
477
+ />
478
+ )}
479
+ </div>
480
+ </div>
481
+ </div>
482
+ {/* Rejection reason input */}
483
+ {showRejectInput && (
484
+ <div className="px-4 pb-4 border-t border-zinc-200 dark:border-zinc-700" onClick={(e) => e.stopPropagation()}>
485
+ <div className="mt-3">
486
+ <Input
487
+ type="text"
488
+ value={rejectReason}
489
+ onChange={(e) => setRejectReason(e.target.value)}
490
+ onKeyDown={(e) => {
491
+ e.stopPropagation();
492
+ if (e.key === 'Enter' && rejectReason.trim()) {
493
+ handleRejectConfirm(e as unknown as React.MouseEvent);
494
+ }
495
+ if (e.key === 'Escape') {
496
+ handleRejectCancel(e as unknown as React.MouseEvent);
497
+ }
498
+ }}
499
+ placeholder="Rejection reason..."
500
+ size="sm"
501
+ error
502
+ autoFocus
503
+ data-testid={`reject-reason-input-${item.id}`}
504
+ />
505
+ <div className="flex items-center gap-1.5 mt-2">
506
+ <Button
507
+ onClick={handleRejectConfirm}
508
+ disabled={!rejectReason.trim()}
509
+ variant="destructive"
510
+ size="sm"
511
+ data-testid={`reject-confirm-${item.id}`}
512
+ >
513
+ Reject
514
+ </Button>
515
+ <Button
516
+ onClick={handleRejectCancel}
517
+ variant="ghost"
518
+ size="sm"
519
+ data-testid={`reject-cancel-${item.id}`}
520
+ >
521
+ Cancel
522
+ </Button>
523
+ </div>
524
+ </div>
525
+ </div>
526
+ )}
527
+ {/* Rejection banner — hidden for done cards */}
528
+ {item.rejection_reason && !isDone && (() => {
529
+ let history: RejectionHistoryEntry[] = [];
530
+ try {
531
+ history = item.rejection_history ? JSON.parse(item.rejection_history) : [];
532
+ } catch { /* degrade gracefully */ }
533
+ const currentRound = item.rejection_count || 1;
534
+ const hasMultipleRounds = history.length > 1;
535
+
536
+ return (
537
+ <div>
538
+ <div
539
+ onClick={(e) => {
540
+ e.stopPropagation();
541
+ if (hasMultipleRounds) setRejectionExpanded(!rejectionExpanded);
542
+ }}
543
+ className={`bg-red-50 dark:bg-red-950/40 ${hasMultipleRounds ? 'cursor-pointer' : ''}`}
544
+ style={{ padding: '10px 16px' }}
545
+ data-testid={`rejection-banner-${item.id}`}
546
+ >
547
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
548
+ <span className="text-red-700 dark:text-red-400" style={{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em' }}>
549
+ Rejected
550
+ </span>
551
+ <span className="bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-400" style={{ fontSize: '11px', padding: '1px 8px', borderRadius: '12px', fontWeight: 600 }}>
552
+ Round {currentRound}
553
+ </span>
554
+ {hasMultipleRounds && (
555
+ <span className="text-red-600 dark:text-red-400" style={{ fontSize: '11px', opacity: 0.5, marginLeft: 'auto' }}>
556
+ {rejectionExpanded ? '▲' : '▼'}
557
+ </span>
558
+ )}
559
+ </div>
560
+ <div className="text-red-700/75 dark:text-red-400/75" style={{ fontSize: '14px', lineHeight: 1.4 }}>
561
+ {item.rejection_reason}
562
+ </div>
563
+ </div>
564
+ {/* Expanded stacked history */}
565
+ {rejectionExpanded && hasMultipleRounds && (
566
+ <div
567
+ onClick={(e) => e.stopPropagation()}
568
+ className="bg-red-50 dark:bg-red-950/40"
569
+ style={{ padding: '12px 16px' }}
570
+ >
571
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' }}>
572
+ <span className="text-red-700 dark:text-red-400" style={{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em' }}>
573
+ Rejections
574
+ </span>
575
+ <span className="bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-400" style={{ fontSize: '11px', padding: '1px 8px', borderRadius: '12px', fontWeight: 600 }}>
576
+ {history.length}
577
+ </span>
578
+ </div>
579
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
580
+ {history.map((entry) => {
581
+ const isLatest = entry.round === currentRound;
582
+ return (
583
+ <div key={entry.round} style={{ display: 'flex', gap: '10px', alignItems: 'flex-start' }}>
584
+ <span
585
+ className={isLatest
586
+ ? 'bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-400'
587
+ : 'bg-red-100/60 dark:bg-red-900/20 text-red-600/60 dark:text-red-400/50'
588
+ }
589
+ style={{
590
+ fontSize: '11px',
591
+ fontWeight: 600,
592
+ padding: '2px 8px',
593
+ borderRadius: '8px',
594
+ flexShrink: 0,
595
+ marginTop: '1px',
596
+ }}
597
+ >
598
+ R{entry.round}
599
+ </span>
600
+ <div>
601
+ <div className="text-red-700/75 dark:text-red-400/75" style={{ fontSize: '14px', lineHeight: 1.4 }}>
602
+ {entry.reason}
603
+ </div>
604
+ {entry.at && (
605
+ <div className="text-red-600/40 dark:text-red-400/30" style={{ fontSize: '11px', marginTop: '2px' }}>
606
+ {formatRejectionTime(entry.at)}
607
+ </div>
608
+ )}
609
+ </div>
610
+ </div>
611
+ );
612
+ })}
613
+ </div>
614
+ </div>
615
+ )}
616
+ </div>
617
+ );
618
+ })()}
619
+ {/* Show expandable section for features with chores or bugs */}
620
+ {(hasChores || hasBugs) && (
621
+ <div className={"border-t border-zinc-200 dark:border-zinc-700"}>
622
+ <button
623
+ onClick={() => setExpanded(!expanded)}
624
+ className={`w-full px-4 py-2 flex items-start gap-2 text-base transition-colors duration-200 ease-out ${
625
+ isDone
626
+ ? 'text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700/50'
627
+ : 'text-zinc-600 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700/50'
628
+ }`}
629
+ >
630
+ <span className="mt-0.5">{expanded ? '▼' : '▶'}</span>
631
+ <div className="flex flex-col gap-1">
632
+ {hasChores && (
633
+ <div className="flex items-center gap-2">
634
+ <TypeIcon type="chore" />
635
+ <span>
636
+ {isDone
637
+ ? `${allChores.length === 0 ? 'no' : allChores.length} chore${allChores.length !== 1 ? 's' : ''}`
638
+ : `${incompleteChores.length === 0 ? 'no' : incompleteChores.length}${item.mode ? ` ${item.mode} mode` : ''} chore${incompleteChores.length !== 1 ? 's' : ''} left`}
639
+ </span>
640
+ </div>
641
+ )}
642
+ {hasBugs && (
643
+ <div className="flex items-center gap-2">
644
+ <TypeIcon type="bug" />
645
+ <span>
646
+ {isDone
647
+ ? `${allBugs.length === 0 ? 'no' : allBugs.length} bug${allBugs.length !== 1 ? 's' : ''}`
648
+ : `${incompleteBugs.length === 0 ? 'no' : incompleteBugs.length} bug${incompleteBugs.length !== 1 ? 's' : ''} left`}
649
+ </span>
650
+ </div>
651
+ )}
652
+ </div>
653
+ </button>
654
+ {expanded && (
655
+ <div className="px-4 pb-3 space-y-1.5">
656
+ {item.rejection_count > 0 ? (
657
+ <RejectionTimeline item={item} allChores={allChores} allBugs={allBugs} isDone={isDone} />
658
+ ) : (
659
+ <>
660
+ {allChores.map((chore) => {
661
+ const isComplete = chore.status === 'done';
662
+ return (
663
+ <Link
664
+ key={chore.id}
665
+ to={`/work/${chore.id}`}
666
+ viewTransition
667
+ className={`block py-1.5 px-3 text-base rounded transition-colors duration-200 ease-out ${
668
+ isComplete
669
+ ? 'bg-zinc-100 dark:bg-zinc-800/50'
670
+ : 'hover:bg-zinc-100 dark:hover:bg-zinc-700'
671
+ }`}
672
+ >
673
+ <div className="flex items-center gap-3">
674
+ <span className={`font-mono ${isComplete ? 'text-zinc-500' : 'text-zinc-400'}`}>#{chore.id}</span>
675
+ {!isDone && chore.mode && MODE_LABELS[chore.mode] && (
676
+ <span className={`px-1 py-0.5 rounded text-[10px] ${MODE_LABELS[chore.mode].color}`}>
677
+ {getModeLabel(chore)}
678
+ </span>
679
+ )}
680
+ <span className={`truncate ${
681
+ isComplete
682
+ ? 'text-zinc-500'
683
+ : 'text-zinc-700 dark:text-zinc-300'
684
+ }`}>
685
+ {chore.title || <span className="text-zinc-400 italic">(Untitled)</span>}
686
+ </span>
687
+ </div>
688
+ </Link>
689
+ );
690
+ })}
691
+ {allBugs.map((bug) => {
692
+ const isComplete = bug.status === 'done';
693
+ return (
694
+ <Link
695
+ key={bug.id}
696
+ to={`/work/${bug.id}`}
697
+ viewTransition
698
+ className={`block py-1.5 px-3 text-base rounded transition-colors duration-200 ease-out ${
699
+ isComplete
700
+ ? 'bg-zinc-100 dark:bg-zinc-800/50'
701
+ : 'hover:bg-zinc-100 dark:hover:bg-zinc-700'
702
+ }`}
703
+ >
704
+ <div className="flex items-center gap-3">
705
+ <span className={`font-mono ${isComplete ? 'text-zinc-500' : 'text-zinc-400'}`}>#{bug.id}</span>
706
+ <TypeIcon type="bug" />
707
+ <span className={`truncate ${
708
+ isComplete
709
+ ? 'text-zinc-500'
710
+ : 'text-zinc-700 dark:text-zinc-300'
711
+ }`}>
712
+ {bug.title || <span className="text-zinc-400 italic">(Untitled)</span>}
713
+ </span>
714
+ </div>
715
+ </Link>
716
+ );
717
+ })}
718
+ </>
719
+ )}
720
+ </div>
721
+ )}
722
+ </div>
723
+ )}
724
+ </div>
725
+ </WaveCompletionAnimation>
726
+ );
727
+
728
+ if (isHighlighted) {
729
+ return (
730
+ <div
731
+ className="rounded-xl"
732
+ style={{ animation: 'highlight-pulse 2s ease-in-out infinite' }}
733
+ >
734
+ {cardContent}
735
+ </div>
736
+ );
737
+ }
738
+
739
+ return cardContent;
740
+ });