jettypod 4.4.116 → 4.4.120

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 (162) hide show
  1. package/.env +7 -0
  2. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +124 -48
  3. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +171 -58
  4. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +161 -10
  5. package/apps/dashboard/app/api/tests/run/stream/route.ts +13 -1
  6. package/apps/dashboard/app/api/usage/route.ts +17 -0
  7. package/apps/dashboard/app/api/work/[id]/route.ts +35 -0
  8. package/apps/dashboard/app/api/work/[id]/status/route.ts +43 -1
  9. package/apps/dashboard/app/connect-claude/page.tsx +24 -0
  10. package/apps/dashboard/app/decision/[id]/page.tsx +14 -14
  11. package/apps/dashboard/app/demo/gates/page.tsx +42 -42
  12. package/apps/dashboard/app/design-system/page.tsx +868 -0
  13. package/apps/dashboard/app/globals.css +6 -2
  14. package/apps/dashboard/app/install-claude/page.tsx +9 -7
  15. package/apps/dashboard/app/layout.tsx +17 -5
  16. package/apps/dashboard/app/login/page.tsx +250 -0
  17. package/apps/dashboard/app/page.tsx +11 -9
  18. package/apps/dashboard/app/settings/page.tsx +4 -2
  19. package/apps/dashboard/app/signup/page.tsx +245 -0
  20. package/apps/dashboard/app/subscribe/page.tsx +11 -0
  21. package/apps/dashboard/app/welcome/page.tsx +24 -1
  22. package/apps/dashboard/app/work/[id]/page.tsx +34 -50
  23. package/apps/dashboard/components/AppShell.tsx +95 -55
  24. package/apps/dashboard/components/CardMenu.tsx +56 -13
  25. package/apps/dashboard/components/ClaudePanel.tsx +301 -582
  26. package/apps/dashboard/components/ClaudePanelInput.tsx +23 -14
  27. package/apps/dashboard/components/ConnectClaudeScreen.tsx +210 -0
  28. package/apps/dashboard/components/CopyableId.tsx +3 -3
  29. package/apps/dashboard/components/DetailReviewActions.tsx +109 -0
  30. package/apps/dashboard/components/DragContext.tsx +75 -65
  31. package/apps/dashboard/components/DraggableCard.tsx +6 -46
  32. package/apps/dashboard/components/DropZone.tsx +2 -2
  33. package/apps/dashboard/components/EditableDetailDescription.tsx +1 -1
  34. package/apps/dashboard/components/EditableTitle.tsx +26 -6
  35. package/apps/dashboard/components/ElapsedTimer.tsx +54 -0
  36. package/apps/dashboard/components/EpicGroup.tsx +329 -0
  37. package/apps/dashboard/components/GateCard.tsx +100 -16
  38. package/apps/dashboard/components/GateChoiceCard.tsx +15 -17
  39. package/apps/dashboard/components/InstallClaudeScreen.tsx +140 -51
  40. package/apps/dashboard/components/JettyLoader.tsx +38 -0
  41. package/apps/dashboard/components/KanbanBoard.tsx +147 -766
  42. package/apps/dashboard/components/KanbanCard.tsx +506 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +12 -0
  44. package/apps/dashboard/components/MainNav.tsx +20 -54
  45. package/apps/dashboard/components/MessageBlock.tsx +391 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -15
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +214 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +11 -21
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +36 -8
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +25 -25
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +265 -301
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +97 -74
  53. package/apps/dashboard/components/ReviewFooter.tsx +141 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -18
  55. package/apps/dashboard/components/SubscribeContent.tsx +206 -0
  56. package/apps/dashboard/components/TestTree.tsx +15 -14
  57. package/apps/dashboard/components/TipCard.tsx +177 -0
  58. package/apps/dashboard/components/Toast.tsx +5 -5
  59. package/apps/dashboard/components/TypeIcon.tsx +56 -0
  60. package/apps/dashboard/components/UpgradeBanner.tsx +30 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +61 -62
  62. package/apps/dashboard/components/WelcomeScreen.tsx +25 -27
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -4
  64. package/apps/dashboard/components/WorkItemTree.tsx +9 -28
  65. package/apps/dashboard/components/settings/AccountSection.tsx +169 -0
  66. package/apps/dashboard/components/settings/EnvVarsSection.tsx +54 -79
  67. package/apps/dashboard/components/settings/GeneralSection.tsx +26 -31
  68. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -4
  69. package/apps/dashboard/components/ui/Button.tsx +104 -0
  70. package/apps/dashboard/components/ui/Input.tsx +78 -0
  71. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +408 -105
  72. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -4
  73. package/apps/dashboard/contexts/UsageContext.tsx +155 -0
  74. package/apps/dashboard/contexts/usageHelpers.js +9 -0
  75. package/apps/dashboard/electron/ipc-handlers.js +281 -88
  76. package/apps/dashboard/electron/main.js +691 -131
  77. package/apps/dashboard/electron/preload.js +25 -4
  78. package/apps/dashboard/electron/session-manager.js +163 -0
  79. package/apps/dashboard/electron-builder.config.js +3 -5
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/lib/backlog-parser.ts +50 -0
  83. package/apps/dashboard/lib/claude-process-manager.ts +50 -11
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/db-bridge.ts +33 -0
  86. package/apps/dashboard/lib/db.ts +136 -20
  87. package/apps/dashboard/lib/kanban-utils.ts +70 -0
  88. package/apps/dashboard/lib/run-migrations.js +27 -2
  89. package/apps/dashboard/lib/session-state-machine.ts +3 -0
  90. package/apps/dashboard/lib/session-stream-manager.ts +144 -38
  91. package/apps/dashboard/lib/shadows.ts +7 -0
  92. package/apps/dashboard/lib/tests.ts +3 -1
  93. package/apps/dashboard/lib/utils.ts +6 -0
  94. package/apps/dashboard/next.config.js +35 -14
  95. package/apps/dashboard/package.json +6 -3
  96. package/apps/dashboard/public/bug-icon.svg +9 -0
  97. package/apps/dashboard/public/buoy-icon.svg +9 -0
  98. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  99. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  100. package/apps/dashboard/public/in-flight-seagull.svg +9 -0
  101. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  102. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  103. package/apps/dashboard/public/jettypod_logo.png +0 -0
  104. package/apps/dashboard/public/pier-icon.svg +14 -0
  105. package/apps/dashboard/public/star-icon.svg +9 -0
  106. package/apps/dashboard/public/wrench-icon.svg +9 -0
  107. package/apps/dashboard/scripts/upload-to-r2.js +89 -0
  108. package/apps/dashboard/scripts/ws-server.js +191 -0
  109. package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
  110. package/apps/update-server/package.json +16 -0
  111. package/apps/update-server/schema.sql +31 -0
  112. package/apps/update-server/src/index.ts +1085 -0
  113. package/apps/update-server/tsconfig.json +16 -0
  114. package/apps/update-server/wrangler.toml +35 -0
  115. package/cucumber.js +9 -3
  116. package/docs/COMMAND_REFERENCE.md +34 -0
  117. package/hooks/post-checkout +32 -75
  118. package/hooks/post-merge +111 -10
  119. package/jest.setup.js +1 -0
  120. package/jettypod.js +54 -116
  121. package/lib/chore-taxonomy.js +33 -10
  122. package/lib/database.js +36 -16
  123. package/lib/db-watcher.js +1 -1
  124. package/lib/git-hooks/pre-commit +1 -1
  125. package/lib/jettypod-backup.js +27 -4
  126. package/lib/migrations/027-plan-at-creation-column.js +33 -0
  127. package/lib/migrations/028-ready-for-review-column.js +27 -0
  128. package/lib/migrations/029-remove-autoincrement.js +307 -0
  129. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  130. package/lib/migrations/index.js +47 -4
  131. package/lib/schema.js +13 -6
  132. package/lib/seed-onboarding.js +101 -69
  133. package/lib/update-command/index.js +9 -175
  134. package/lib/work-commands/index.js +129 -16
  135. package/lib/work-tracking/index.js +86 -46
  136. package/lib/worktree-diagnostics.js +16 -16
  137. package/lib/worktree-facade.js +1 -1
  138. package/lib/worktree-manager.js +8 -8
  139. package/lib/worktree-reconciler.js +5 -5
  140. package/package.json +9 -2
  141. package/scripts/ndjson-to-cucumber-json.js +152 -0
  142. package/scripts/postinstall.js +25 -0
  143. package/skills-templates/bug-mode/SKILL.md +39 -28
  144. package/skills-templates/bug-planning/SKILL.md +25 -29
  145. package/skills-templates/chore-mode/SKILL.md +131 -68
  146. package/skills-templates/chore-mode/verification.js +51 -10
  147. package/skills-templates/chore-planning/SKILL.md +47 -18
  148. package/skills-templates/epic-planning/SKILL.md +68 -48
  149. package/skills-templates/external-transition/SKILL.md +47 -47
  150. package/skills-templates/feature-planning/SKILL.md +83 -73
  151. package/skills-templates/production-mode/SKILL.md +49 -49
  152. package/skills-templates/request-routing/SKILL.md +27 -14
  153. package/skills-templates/simple-improvement/SKILL.md +68 -44
  154. package/skills-templates/speed-mode/SKILL.md +209 -128
  155. package/skills-templates/stable-mode/SKILL.md +105 -94
  156. package/templates/bdd-guidance.md +139 -0
  157. package/templates/bdd-scaffolding/wait.js +18 -0
  158. package/templates/bdd-scaffolding/world.js +19 -0
  159. package/.jettypod-backup/work.db +0 -0
  160. package/apps/dashboard/app/access-code/page.tsx +0 -110
  161. package/lib/discovery-checkpoint.js +0 -123
  162. package/skills-templates/project-discovery/SKILL.md +0 -372
@@ -1,726 +1,39 @@
1
1
  'use client';
2
2
 
3
3
  import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
4
- import Link from 'next/link';
5
- import { useRouter } from 'next/navigation';
6
- import { AnimatePresence } from 'framer-motion';
7
- import { useDroppable } from '@dnd-kit/core';
8
4
  import type { WorkItem, InFlightItem, KanbanGroup } from '@/lib/db';
9
5
  import type { UndoAction } from '@/lib/undoStack';
10
6
  import type { Session } from '../contexts/ClaudeSessionContext';
11
- import { EditableTitle } from './EditableTitle';
12
- import { CardMenu } from './CardMenu';
13
- import { DragProvider, useDragContext } from './DragContext';
7
+ import { DragProvider } from './DragContext';
14
8
  import { DraggableCard } from './DraggableCard';
15
9
  import { DropZone } from './DropZone';
16
- import { PlaceholderCard } from './PlaceholderCard';
17
- import { CopyableId } from './CopyableId';
18
- import { WaveCompletionAnimation } from './WaveCompletionAnimation';
19
-
20
- const typeIcons: Record<string, string> = {
21
- epic: '🎯',
22
- feature: '✨',
23
- chore: '🔧',
24
- bug: '🐛',
25
- };
26
-
27
- const modeLabels: Record<string, { label: string; color: string }> = {
28
- speed: { label: 'speed', color: 'bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-300' },
29
- stable: { label: 'stable', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300' },
30
- production: { label: 'prod', color: 'bg-purple-100 text-purple-800 dark:bg-purple-900/50 dark:text-purple-300' },
31
- };
32
-
33
- function getModeLabel(item: WorkItem): string {
34
- if (!item.mode) return '';
35
- const base = modeLabels[item.mode]?.label || item.mode;
36
- if (item.current_step && item.total_steps) {
37
- return `${base} ${item.current_step}/${item.total_steps}`;
10
+ import { KanbanCard } from './KanbanCard';
11
+ import { EpicGroup, MIN_DISPLAY_ORDER, MAX_DISPLAY_ORDER, DISPLAY_ORDER_INCREMENT } from './EpicGroup';
12
+ import { useDragContext } from './DragContext';
13
+ import { shadow } from '@/lib/shadows';
14
+
15
+ const BACKLOG_VISIBLE_LIMIT = 45;
16
+ const DONE_VISIBLE_LIMIT = 15;
17
+
18
+ // Returns the subset of entries to render, respecting soft epic-group boundaries.
19
+ // If adding a group would cross the limit but the count before it was under, include it fully.
20
+ function getVisibleEntries(
21
+ entries: [string, KanbanGroup][],
22
+ limit: number,
23
+ showAll: boolean
24
+ ): { visible: [string, KanbanGroup][]; totalCount: number; hasMore: boolean } {
25
+ const totalCount = entries.reduce((sum, [, g]) => sum + g.items.length, 0);
26
+ if (showAll || totalCount <= limit) {
27
+ return { visible: entries, totalCount, hasMore: false };
38
28
  }
39
- return base;
40
- }
41
-
42
-
43
- // Session indicator icon - shows when item has an active Claude session
44
- function SessionIndicatorIcon({ className }: { className?: string }) {
45
- return (
46
- <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
47
- <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" />
48
- </svg>
49
- );
50
- }
51
-
52
- interface KanbanCardProps {
53
- item: WorkItem;
54
- epicTitle?: string | null;
55
- showEpic?: boolean;
56
- isInFlight?: boolean;
57
- onTitleSave?: (id: number, newTitle: string) => Promise<void>;
58
- onStatusChange?: (id: number, newStatus: string) => Promise<void | { success: boolean; notFound?: boolean }>;
59
- onReject?: (id: number, reason: string) => Promise<void>;
60
- onTriggerClaude?: (id: number, title: string, type: string) => void;
61
- hasActiveSession?: boolean;
62
- onOpenSession?: (id: string) => void;
63
- // Animation state lifted to board level
64
- isCompletingAnimation?: boolean;
65
- onAnimationComplete?: () => void;
66
- }
67
-
68
- function KanbanCard({ item, epicTitle, showEpic = false, isInFlight = false, onTitleSave, onStatusChange, onReject, onTriggerClaude, hasActiveSession, onOpenSession, isCompletingAnimation = false, onAnimationComplete }: KanbanCardProps) {
69
- const [expanded, setExpanded] = useState(false);
70
- const [showRejectInput, setShowRejectInput] = useState(false);
71
- const [rejectReason, setRejectReason] = useState('');
72
- const router = useRouter();
73
-
74
- const handleOpenSession = (e: React.MouseEvent) => {
75
- e.stopPropagation(); // Prevent card navigation
76
- if (onOpenSession) {
77
- onOpenSession(String(item.id));
78
- }
79
- };
80
-
81
- const handleStart = async (e: React.MouseEvent) => {
82
- e.stopPropagation(); // Prevent card navigation
83
- if (onStatusChange) {
84
- await onStatusChange(item.id, 'in_progress');
85
- if (onTriggerClaude) {
86
- onTriggerClaude(item.id, item.title, item.type);
87
- }
88
- }
89
- };
90
-
91
- const canStart = item.status === 'backlog' || item.status === 'cancelled';
92
-
93
- // A top-level item is reviewable when it's in-flight (in_progress) or in the done column
94
- // Non-top-level items (chores/bugs with parents) are not reviewable
95
- const isTopLevel = !item.parent_id;
96
- const isReviewable = isTopLevel && (item.status === 'in_progress' || item.status === 'done');
97
-
98
- const handleAccept = async (e: React.MouseEvent) => {
99
- e.stopPropagation();
100
- if (onStatusChange) {
101
- await onStatusChange(item.id, 'done');
102
- }
103
- };
104
-
105
- const handleRejectClick = (e: React.MouseEvent) => {
106
- e.stopPropagation();
107
- setShowRejectInput(true);
108
- };
109
-
110
- const handleRejectConfirm = async (e: React.MouseEvent) => {
111
- e.stopPropagation();
112
- if (onReject && rejectReason.trim()) {
113
- await onReject(item.id, rejectReason.trim());
114
- setShowRejectInput(false);
115
- setRejectReason('');
116
- }
117
- };
118
-
119
- const handleRejectCancel = (e: React.MouseEvent) => {
120
- e.stopPropagation();
121
- setShowRejectInput(false);
122
- setRejectReason('');
123
- };
124
-
125
- // Calculate chores for expandable section
126
- const allChores = item.chores || [];
127
- const incompleteChores = allChores.filter(c => c.status !== 'done');
128
- const hasChores = allChores.length > 0;
129
- const hasIncompleteChores = incompleteChores.length > 0;
130
-
131
- // Calculate bugs for expandable section
132
- const allBugs = item.bugs || [];
133
- const incompleteBugs = allBugs.filter(b => b.status !== 'done');
134
- const hasBugs = allBugs.length > 0;
135
-
136
- const handleCardClick = () => {
137
- router.push(`/work/${item.id}`);
138
- };
139
-
140
- const handleTitleSave = async (id: number, newTitle: string) => {
141
- if (onTitleSave) {
142
- await onTitleSave(id, newTitle);
143
- }
144
- };
145
-
146
- // Status changes are now handled by the board-level wrapper that triggers animation
147
- const handleStatusChange = async (id: number, newStatus: string) => {
148
- if (onStatusChange) {
149
- await onStatusChange(id, newStatus);
150
- }
151
- };
152
-
153
- const isDone = item.status === 'done';
154
-
155
- const getCardStyles = () => {
156
- const baseElevation = `
157
- 0 1px 2px rgba(0, 0, 0, 0.03),
158
- 0 2px 4px rgba(0, 0, 0, 0.03),
159
- 0 4px 8px rgba(0, 0, 0, 0.02)
160
- `;
161
- const hoverElevation = `
162
- 0 2px 4px rgba(0, 0, 0, 0.04),
163
- 0 4px 8px rgba(0, 0, 0, 0.04),
164
- 0 8px 16px rgba(0, 0, 0, 0.03),
165
- 0 12px 24px rgba(129, 157, 159, 0.08)
166
- `;
167
-
168
- if (isDone) {
169
- return {
170
- className: 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800',
171
- boxShadow: baseElevation,
172
- hoverBoxShadow: hoverElevation,
173
- };
174
- }
175
- if (isInFlight) {
176
- return {
177
- className: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800',
178
- boxShadow: baseElevation,
179
- hoverBoxShadow: hoverElevation,
180
- };
181
- }
182
- return {
183
- className: 'bg-white dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700',
184
- boxShadow: baseElevation,
185
- hoverBoxShadow: hoverElevation,
186
- };
187
- };
188
-
189
- const cardStyles = getCardStyles();
190
-
191
- return (
192
- <WaveCompletionAnimation isPlaying={isCompletingAnimation} onComplete={onAnimationComplete || (() => {})}>
193
- <div
194
- className={`rounded-xl border transition-all duration-200 hover:-translate-y-0.5 ${cardStyles.className}`}
195
- style={{ boxShadow: cardStyles.boxShadow }}
196
- onMouseEnter={(e) => { e.currentTarget.style.boxShadow = cardStyles.hoverBoxShadow; }}
197
- onMouseLeave={(e) => { e.currentTarget.style.boxShadow = cardStyles.boxShadow; }}
198
- data-testid={`kanban-card-${item.id}`}>
199
- <div
200
- onClick={handleCardClick}
201
- className="block p-3 cursor-pointer"
202
- >
203
- <div className="flex items-start gap-2">
204
- <span className="text-sm flex-shrink-0">{typeIcons[item.type] || '📄'}</span>
205
- <div className="flex-1 min-w-0">
206
- {isDone ? (
207
- /* Compact layout for done cards: ID and title inline, no mode badge */
208
- <div className="flex items-start gap-2">
209
- <CopyableId id={item.id} title={item.title} type={item.type} />
210
- <span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
211
- {item.title || <span className="text-zinc-400 italic">(Untitled)</span>}
212
- </span>
213
- </div>
214
- ) : (
215
- /* Standard layout: ID + mode badge on line 1, title below */
216
- <>
217
- <div className="flex items-center gap-2 mb-1 flex-wrap">
218
- <CopyableId id={item.id} title={item.title} type={item.type} />
219
- {item.mode && modeLabels[item.mode] && (
220
- <span className={`text-xs px-1.5 py-0.5 rounded ${modeLabels[item.mode].color}`}>
221
- {getModeLabel(item)}
222
- </span>
223
- )}
224
- </div>
225
- <EditableTitle
226
- title={item.title}
227
- itemId={item.id}
228
- onSave={handleTitleSave}
229
- />
230
- </>
231
- )}
232
- {showEpic && epicTitle && (
233
- <p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1.5 flex items-center gap-1">
234
- <span>🎯</span>
235
- <span>{epicTitle}</span>
236
- </p>
237
- )}
238
- </div>
239
- <div className="flex items-center gap-1">
240
- {/* Accept/Reject buttons - shown for reviewable top-level items */}
241
- {isReviewable && item.status !== 'done' && onStatusChange && (
242
- <button
243
- onClick={handleAccept}
244
- className="p-1 rounded hover:bg-green-100 dark:hover:bg-green-900/30 text-green-600 dark:text-green-400 transition-colors"
245
- aria-label="Accept work item"
246
- data-testid={`accept-button-${item.id}`}
247
- title="Accept"
248
- >
249
- <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
250
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
251
- </svg>
252
- </button>
253
- )}
254
- {isReviewable && onReject && (
255
- <button
256
- onClick={handleRejectClick}
257
- className="p-1 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 transition-colors"
258
- aria-label="Reject work item"
259
- data-testid={`reject-button-${item.id}`}
260
- title="Reject"
261
- >
262
- <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
263
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
264
- </svg>
265
- </button>
266
- )}
267
- {/* Start button - shown for backlog/cancelled items */}
268
- {canStart && onStatusChange && (
269
- <button
270
- onClick={handleStart}
271
- className="px-2 py-0.5 text-xs rounded border border-zinc-300 dark:border-zinc-600 hover:bg-zinc-100 dark:hover:bg-zinc-700 text-zinc-600 dark:text-zinc-400 transition-colors"
272
- aria-label="Start work"
273
- data-testid={`start-button-${item.id}`}
274
- >
275
- start
276
- </button>
277
- )}
278
- {/* Session indicator - clickable icon to reopen session */}
279
- {hasActiveSession && (
280
- <button
281
- onClick={handleOpenSession}
282
- className="p-1.5 rounded hover:bg-zinc-100 dark:hover:bg-zinc-700 text-blue-500 transition-colors"
283
- aria-label="Open active session"
284
- data-testid={`session-indicator-${item.id}`}
285
- title="Open session"
286
- >
287
- <SessionIndicatorIcon className="w-4 h-4" />
288
- </button>
289
- )}
290
- {onStatusChange && (
291
- <CardMenu
292
- itemId={item.id}
293
- itemTitle={item.title}
294
- itemType={item.type}
295
- currentStatus={item.status}
296
- onStatusChange={handleStatusChange}
297
- onTriggerClaude={onTriggerClaude}
298
- hasActiveSession={hasActiveSession}
299
- onOpenSession={onOpenSession}
300
- />
301
- )}
302
- </div>
303
- </div>
304
- </div>
305
- {/* Rejection reason input */}
306
- {showRejectInput && (
307
- <div className="px-3 pb-3 border-t border-zinc-200 dark:border-zinc-700" onClick={(e) => e.stopPropagation()}>
308
- <div className="mt-2">
309
- <input
310
- type="text"
311
- value={rejectReason}
312
- onChange={(e) => setRejectReason(e.target.value)}
313
- onKeyDown={(e) => {
314
- if (e.key === 'Enter' && rejectReason.trim()) {
315
- handleRejectConfirm(e as unknown as React.MouseEvent);
316
- }
317
- if (e.key === 'Escape') {
318
- handleRejectCancel(e as unknown as React.MouseEvent);
319
- }
320
- }}
321
- placeholder="Rejection reason..."
322
- className="w-full text-xs px-2 py-1.5 rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-1 focus:ring-red-400"
323
- autoFocus
324
- data-testid={`reject-reason-input-${item.id}`}
325
- />
326
- <div className="flex items-center gap-1 mt-1.5">
327
- <button
328
- onClick={handleRejectConfirm}
329
- disabled={!rejectReason.trim()}
330
- className="px-2 py-0.5 text-xs rounded bg-red-500 text-white hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
331
- data-testid={`reject-confirm-${item.id}`}
332
- >
333
- Reject
334
- </button>
335
- <button
336
- onClick={handleRejectCancel}
337
- className="px-2 py-0.5 text-xs rounded border border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors"
338
- data-testid={`reject-cancel-${item.id}`}
339
- >
340
- Cancel
341
- </button>
342
- </div>
343
- </div>
344
- </div>
345
- )}
346
- {/* Rejected indicator */}
347
- {item.rejection_reason && (
348
- <div className="px-3 pb-2 border-t border-red-200 dark:border-red-800">
349
- <div className="mt-1.5 flex items-start gap-1.5 text-xs text-red-600 dark:text-red-400">
350
- <span className="flex-shrink-0">⚠️</span>
351
- <span className="italic">{item.rejection_reason}</span>
352
- </div>
353
- </div>
354
- )}
355
- {/* Show expandable section for features with chores or bugs */}
356
- {(hasChores || hasBugs) && (
357
- <div className={`border-t ${isDone ? 'border-green-200 dark:border-green-800' : 'border-zinc-200 dark:border-zinc-700'}`}>
358
- <button
359
- onClick={() => setExpanded(!expanded)}
360
- className={`w-full px-3 py-1.5 flex items-start gap-1.5 text-xs transition-colors ${
361
- isDone
362
- ? 'text-green-700 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/30'
363
- : 'text-zinc-600 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700/50'
364
- }`}
365
- >
366
- <span className="mt-0.5">{expanded ? '▼' : '▶'}</span>
367
- <div className="flex flex-col gap-0.5">
368
- {hasChores && (
369
- <div className="flex items-center gap-1.5">
370
- <span>🔧</span>
371
- <span>
372
- {isDone
373
- ? `${allChores.length === 0 ? 'no' : allChores.length} chore${allChores.length !== 1 ? 's' : ''}`
374
- : `${incompleteChores.length === 0 ? 'no' : incompleteChores.length}${item.mode ? ` ${item.mode} mode` : ''} chore${incompleteChores.length !== 1 ? 's' : ''} left`}
375
- </span>
376
- </div>
377
- )}
378
- {hasBugs && (
379
- <div className="flex items-center gap-1.5">
380
- <span>🐛</span>
381
- <span>
382
- {isDone
383
- ? `${allBugs.length === 0 ? 'no' : allBugs.length} bug${allBugs.length !== 1 ? 's' : ''}`
384
- : `${incompleteBugs.length === 0 ? 'no' : incompleteBugs.length} bug${incompleteBugs.length !== 1 ? 's' : ''} left`}
385
- </span>
386
- </div>
387
- )}
388
- </div>
389
- </button>
390
- {expanded && (
391
- <div className="px-3 pb-2 space-y-1">
392
- {allChores.map((chore) => {
393
- const isComplete = chore.status === 'done';
394
- return (
395
- <Link
396
- key={chore.id}
397
- href={`/work/${chore.id}`}
398
- className={`block py-1 px-2 text-xs rounded transition-colors ${
399
- isComplete
400
- ? 'bg-green-100 dark:bg-green-900/30 border border-green-200 dark:border-green-800/50'
401
- : 'hover:bg-zinc-100 dark:hover:bg-zinc-700'
402
- }`}
403
- >
404
- <div className="flex items-center gap-2">
405
- <span className={`font-mono ${isComplete ? 'text-zinc-500' : 'text-zinc-400'}`}>#{chore.id}</span>
406
- {!isDone && chore.mode && modeLabels[chore.mode] && (
407
- <span className={`px-1 py-0.5 rounded text-[10px] ${modeLabels[chore.mode].color}`}>
408
- {getModeLabel(chore)}
409
- </span>
410
- )}
411
- <span className={`truncate ${
412
- isComplete
413
- ? 'text-zinc-500'
414
- : 'text-zinc-700 dark:text-zinc-300'
415
- }`}>
416
- {chore.title || <span className="text-zinc-400 italic">(Untitled)</span>}
417
- </span>
418
- </div>
419
- </Link>
420
- );
421
- })}
422
- {allBugs.map((bug) => {
423
- const isComplete = bug.status === 'done';
424
- return (
425
- <Link
426
- key={bug.id}
427
- href={`/work/${bug.id}`}
428
- className={`block py-1 px-2 text-xs rounded transition-colors ${
429
- isComplete
430
- ? 'bg-green-100 dark:bg-green-900/30 border border-green-200 dark:border-green-800/50'
431
- : 'hover:bg-zinc-100 dark:hover:bg-zinc-700'
432
- }`}
433
- >
434
- <div className="flex items-center gap-2">
435
- <span className={`font-mono ${isComplete ? 'text-zinc-500' : 'text-zinc-400'}`}>#{bug.id}</span>
436
- <span>🐛</span>
437
- <span className={`truncate ${
438
- isComplete
439
- ? 'text-zinc-500'
440
- : 'text-zinc-700 dark:text-zinc-300'
441
- }`}>
442
- {bug.title || <span className="text-zinc-400 italic">(Untitled)</span>}
443
- </span>
444
- </div>
445
- </Link>
446
- );
447
- })}
448
- </div>
449
- )}
450
- </div>
451
- )}
452
- </div>
453
- </WaveCompletionAnimation>
454
- );
455
- }
456
-
457
- // Safe bounds for display_order to prevent overflow
458
- const MIN_DISPLAY_ORDER = 0;
459
- const MAX_DISPLAY_ORDER = Number.MAX_SAFE_INTEGER - 1000;
460
- const DISPLAY_ORDER_INCREMENT = 10;
461
-
462
- interface EpicGroupProps {
463
- epicId: number | null;
464
- epicTitle: string | null;
465
- items: WorkItem[];
466
- isInFlight?: boolean;
467
- isDraggable?: boolean;
468
- onTitleSave?: (id: number, newTitle: string) => Promise<void>;
469
- onStatusChange?: (id: number, newStatus: string) => Promise<void | { success: boolean; notFound?: boolean }>;
470
- onReject?: (id: number, reason: string) => Promise<void>;
471
- onEpicAssign?: (id: number, epicId: number | null) => Promise<void>;
472
- onOrderChange?: (id: number, newOrder: number) => Promise<void>;
473
- onTriggerClaude?: (id: number, title: string, type: string) => void;
474
- activeSessions?: Map<string, Session>;
475
- onOpenSession?: (id: string) => void;
476
- onError?: (message: string) => void;
477
- // Animation state lifted to board level
478
- animatingItemId?: number | null;
479
- onAnimationComplete?: () => void;
480
- }
481
-
482
- function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable = true, onTitleSave, onStatusChange, onReject, onEpicAssign, onOrderChange, onTriggerClaude, activeSessions, onOpenSession, onError, animatingItemId, onAnimationComplete }: EpicGroupProps) {
483
- const containerRef = useRef<HTMLDivElement>(null);
484
- const { isDragging, draggedItem, activeEpicZone, activeDropZone, dragPosition, draggedCardHeight, registerEpicDropZone, unregisterEpicDropZone, getCardPositions } = useDragContext();
485
-
486
- // Use @dnd-kit's useDroppable for epic zone collision detection
487
- const zoneId = epicId !== null ? `epic-${epicId}` : undefined;
488
- const { setNodeRef } = useDroppable({
489
- id: zoneId || 'ungrouped',
490
- disabled: epicId === null, // Don't use droppable for ungrouped section
491
- data: { epicId },
492
- });
493
-
494
- // Combine refs
495
- const setRefs = useCallback((node: HTMLDivElement | null) => {
496
- if (epicId !== null) {
497
- setNodeRef(node);
498
- }
499
- (containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
500
- }, [epicId, setNodeRef]);
501
-
502
- // Use ref for items to avoid re-registering drop zone when items change
503
- const itemsRef = useRef(items);
504
- itemsRef.current = items;
505
-
506
- // Use ref for callbacks to keep drop zone registration stable
507
- const onOrderChangeRef = useRef(onOrderChange);
508
- onOrderChangeRef.current = onOrderChange;
509
-
510
- // Use ref for error handler to keep reorder handler stable
511
- const onErrorRef = useRef(onError);
512
- onErrorRef.current = onError;
513
-
514
- // Stable reorder handler that reads from refs
515
- const handleEpicReorder = useCallback(async (itemId: number, pointerY: number) => {
516
- if (!onOrderChangeRef.current) {
517
- return;
518
- }
519
-
520
- // Get current item IDs from ref
521
- const itemIds = new Set(itemsRef.current.map(item => item.id));
522
-
523
- // Use cached card positions from registry, filtered to this epic's items
524
- const allPositions = getCardPositions();
525
- const cardPositions = allPositions
526
- .filter(pos => itemIds.has(pos.id) && pos.id !== itemId)
527
- .map(pos => ({
528
- id: pos.id,
529
- midY: (pos.rect.top + pos.rect.bottom) / 2,
530
- }));
531
-
532
- // Skip reorder if this is the only item in the epic (no other cards to reorder against)
533
- if (cardPositions.length === 0) {
534
- return;
535
- }
536
-
537
- // Sort by Y position
538
- cardPositions.sort((a, b) => a.midY - b.midY);
539
-
540
- // Find insertion index based on pointer Y
541
- let insertIndex = cardPositions.length;
542
- for (let i = 0; i < cardPositions.length; i++) {
543
- if (pointerY < cardPositions[i].midY) {
544
- insertIndex = i;
545
- break;
546
- }
547
- }
548
-
549
- // Calculate new display_order with bounds checking
550
- let newOrder = insertIndex * DISPLAY_ORDER_INCREMENT;
551
- // Clamp to safe bounds to prevent overflow
552
- newOrder = Math.max(MIN_DISPLAY_ORDER, Math.min(MAX_DISPLAY_ORDER, newOrder));
553
-
554
- try {
555
- await onOrderChangeRef.current(itemId, newOrder);
556
- } catch (error) {
557
- // Notify user via callback instead of alert() - items remain in original order
558
- const errorMessage = error instanceof Error ? error.message : 'Failed to reorder item. Please try again.';
559
- onErrorRef.current?.(errorMessage);
560
- }
561
- }, [getCardPositions]);
562
-
563
- // Register as epic drop zone - stable registration that doesn't change with items
564
- useEffect(() => {
565
- if (!containerRef.current || !onEpicAssign || epicId === null) return;
566
-
567
- const zoneId = `epic-${epicId}`;
568
- registerEpicDropZone(zoneId, {
569
- epicId,
570
- element: containerRef.current,
571
- onEpicAssign,
572
- onReorder: handleEpicReorder,
573
- });
574
-
575
- return () => {
576
- unregisterEpicDropZone(zoneId);
577
- };
578
- }, [epicId, onEpicAssign, handleEpicReorder, registerEpicDropZone, unregisterEpicDropZone]);
579
-
580
- // Check if this epic zone is the active drop target
581
- const isActiveTarget = activeEpicZone === `epic-${epicId}`;
582
-
583
- // Check if the dragged item is from a different epic or same epic
584
- const draggedItemEpicId = draggedItem ? (draggedItem.parent_id || draggedItem.epic_id) : null;
585
- const isDifferentEpic = isDragging && draggedItem && draggedItemEpicId !== epicId;
586
- const isSameEpic = isDragging && draggedItem && draggedItemEpicId === epicId;
587
-
588
- // Show highlight when dragging an item from different epic over this group (indigo)
589
- const showHighlight = isActiveTarget && isDifferentEpic;
590
- // Show reorder highlight when dragging within same epic (purple)
591
- const showReorderHighlight = isActiveTarget && isSameEpic;
592
-
593
- // For ungrouped section (epicId === null)
594
- const isUngroupedSection = epicId === null;
595
- // Check if cursor is over this ungrouped section (not over any epic zone, but over backlog drop zone)
596
- const isOverUngroupedSection = isUngroupedSection && !activeEpicZone && activeDropZone;
597
-
598
- // Render the ungrouped zone when dragging from an epic (provides drop target), but only highlight when cursor is over it
599
- const shouldRenderUngroupedZone = isUngroupedSection && isDragging && draggedItemEpicId !== null;
600
- const showRemoveFromEpicZone = isOverUngroupedSection && isDragging && draggedItemEpicId !== null;
601
-
602
- // Show reorder for ungrouped section when dragging an ungrouped card and cursor is over it
603
- const showUngroupedReorder = isOverUngroupedSection && isDragging && draggedItemEpicId === null;
604
-
605
- // Calculate insertion preview for this group - only for the active zone
606
- const showPreview = (showReorderHighlight || showRemoveFromEpicZone || showHighlight || showUngroupedReorder) && draggedItem;
607
- let insertAfterItemId: number | null | undefined = undefined; // undefined = no preview, null = at beginning
608
-
609
- if (showPreview && draggedItem) {
610
- const allPositions = getCardPositions();
611
- const itemIds = new Set(items.map(item => item.id));
612
- const groupPositions = allPositions
613
- .filter(pos => itemIds.has(pos.id) && pos.id !== draggedItem.id)
614
- .map(pos => ({
615
- id: pos.id,
616
- midY: (pos.rect.top + pos.rect.bottom) / 2,
617
- }))
618
- .sort((a, b) => a.midY - b.midY);
619
-
620
- // Find which card the pointer is after
621
- insertAfterItemId = null; // Default to beginning
622
- for (const pos of groupPositions) {
623
- if (dragPosition.y > pos.midY) {
624
- insertAfterItemId = pos.id;
625
- } else {
626
- break;
627
- }
628
- }
29
+ const visible: [string, KanbanGroup][] = [];
30
+ let count = 0;
31
+ for (const entry of entries) {
32
+ if (count >= limit) break;
33
+ visible.push(entry);
34
+ count += entry[1].items.length;
629
35
  }
630
-
631
- if (items.length === 0 && !showHighlight && !showReorderHighlight && !shouldRenderUngroupedZone) return null;
632
-
633
- // Standalone done items (single item, no epic) use tighter spacing
634
- const isStandaloneItem = !epicTitle && items.length === 1;
635
-
636
- return (
637
- <div
638
- ref={setRefs}
639
- className={`${isStandaloneItem ? 'mb-2' : 'mb-4 p-2 -mx-2'} rounded-lg transition-all ${
640
- showHighlight
641
- ? 'ring-2 ring-indigo-400 bg-indigo-100/50 dark:bg-indigo-900/30'
642
- : showReorderHighlight
643
- ? 'ring-2 ring-purple-400 bg-purple-100/50 dark:bg-purple-900/30'
644
- : showRemoveFromEpicZone
645
- ? 'ring-2 ring-orange-400 bg-orange-100/50 dark:bg-orange-900/30'
646
- : ''
647
- }`}
648
- data-epic-id={epicId}
649
- >
650
- {epicTitle && (
651
- <div className="flex items-center gap-2 mb-2">
652
- <Link
653
- href={`/work/${epicId}`}
654
- className="flex items-center gap-1.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300"
655
- >
656
- <span>🎯</span>
657
- <span>{epicTitle}</span>
658
- </Link>
659
- {isInFlight && (
660
- <span className="text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300">
661
- in flight
662
- </span>
663
- )}
664
- {showHighlight && (
665
- <span className="text-xs px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-300">
666
- drop to assign
667
- </span>
668
- )}
669
- {showReorderHighlight && (
670
- <span className="text-xs px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300">
671
- reorder
672
- </span>
673
- )}
674
- </div>
675
- )}
676
- {/* Ungrouped section header - shown when dragging from epic */}
677
- {isUngroupedSection && showRemoveFromEpicZone && items.length === 0 && (
678
- <div className="flex items-center gap-2 py-3">
679
- <span className="text-xs font-medium text-orange-600 dark:text-orange-400">
680
- Drop here to remove from epic
681
- </span>
682
- </div>
683
- )}
684
- {isUngroupedSection && items.length > 0 && isDraggable && showRemoveFromEpicZone && (
685
- <div className="flex items-center gap-2 mb-2">
686
- <span className="text-xs px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300">
687
- drop to remove from epic
688
- </span>
689
- </div>
690
- )}
691
- <div className="space-y-2">
692
- {/* Placeholder at the beginning (insertAfterItemId === null) */}
693
- <AnimatePresence>
694
- {insertAfterItemId === null && (
695
- <PlaceholderCard key="placeholder-start" height={draggedCardHeight} />
696
- )}
697
- </AnimatePresence>
698
- {items.map((item) => (
699
- <div key={item.id}>
700
- <DraggableCard item={item} disabled={!isDraggable}>
701
- <KanbanCard
702
- item={item}
703
- onTitleSave={onTitleSave}
704
- onStatusChange={onStatusChange}
705
- onReject={onReject}
706
- onTriggerClaude={onTriggerClaude}
707
- hasActiveSession={activeSessions?.has(String(item.id))}
708
- onOpenSession={onOpenSession}
709
- isCompletingAnimation={animatingItemId === item.id}
710
- onAnimationComplete={onAnimationComplete}
711
- />
712
- </DraggableCard>
713
- {/* Placeholder after this card */}
714
- <AnimatePresence>
715
- {insertAfterItemId === item.id && (
716
- <PlaceholderCard key={`placeholder-${item.id}`} height={draggedCardHeight} />
717
- )}
718
- </AnimatePresence>
719
- </div>
720
- ))}
721
- </div>
722
- </div>
723
- );
36
+ return { visible, totalCount, hasMore: count < totalCount };
724
37
  }
725
38
 
726
39
  interface KanbanColumnProps {
@@ -728,45 +41,44 @@ interface KanbanColumnProps {
728
41
  children: React.ReactNode;
729
42
  count: number;
730
43
  onAdd?: () => void;
44
+ addDisabled?: boolean;
731
45
  }
732
46
 
733
- function KanbanColumn({ title, children, count, onAdd }: KanbanColumnProps) {
47
+ function KanbanColumn({ title, children, count, onAdd, addDisabled }: KanbanColumnProps) {
734
48
  const testId = title.toLowerCase().replace(/\s+/g, '-') + '-column';
735
49
  return (
736
- <div className="flex-1 min-w-[300px] max-w-[400px] flex flex-col min-h-0" data-testid={testId}>
50
+ <div className="flex-1 max-w-[600px] flex flex-col min-h-0" data-testid={testId}>
737
51
  <div
738
- className="bg-zinc-100 dark:bg-zinc-900 rounded-xl p-3 flex flex-col flex-1 min-h-0"
739
- style={{
740
- boxShadow: `
741
- 0 1px 2px rgba(0, 0, 0, 0.04),
742
- 0 2px 4px rgba(0, 0, 0, 0.04),
743
- 0 4px 8px rgba(0, 0, 0, 0.04),
744
- 0 8px 16px rgba(0, 0, 0, 0.02),
745
- inset 0 1px 0 rgba(255, 255, 255, 0.5)
746
- `,
747
- }}
52
+ className="bg-zinc-100 dark:bg-zinc-900 rounded-xl p-4 flex flex-col flex-1 min-h-0"
53
+ style={{ boxShadow: shadow.sm }}
748
54
  >
749
- <div className="flex items-center justify-between mb-3 flex-shrink-0">
55
+ <div className="flex items-center justify-between mb-4 flex-shrink-0">
750
56
  <h2 className="font-semibold text-zinc-900 dark:text-zinc-100">{title}</h2>
751
57
  <div className="flex items-center gap-2">
752
58
  {onAdd && (
753
59
  <button
754
60
  onClick={onAdd}
755
- className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-800 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors"
61
+ disabled={addDisabled}
62
+ className={`p-1 rounded transition-colors duration-200 ease-out ${
63
+ addDisabled
64
+ ? 'text-zinc-300 dark:text-zinc-700 cursor-not-allowed'
65
+ : 'hover:bg-zinc-200 dark:hover:bg-zinc-800 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300'
66
+ }`}
756
67
  aria-label={`Add to ${title.toLowerCase()}`}
757
68
  data-testid={`${testId}-add-button`}
69
+ title={addDisabled ? 'Weekly usage limit reached' : `Add to ${title.toLowerCase()}`}
758
70
  >
759
71
  <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
760
72
  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
761
73
  </svg>
762
74
  </button>
763
75
  )}
764
- <span className="text-xs bg-zinc-200 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 px-2 py-0.5 rounded-full">
76
+ <span className="text-xs bg-zinc-200 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 px-3 py-1 rounded-full">
765
77
  {count}
766
78
  </span>
767
79
  </div>
768
80
  </div>
769
- <div className="overflow-y-auto flex-1 min-h-0">
81
+ <div className="overflow-y-auto flex-1 min-h-0 px-1 -mx-1">
770
82
  {children}
771
83
  </div>
772
84
  </div>
@@ -795,25 +107,30 @@ function BacklogDropZoneWrapper({ backlog, onStatusChange, onOrderChange, childr
795
107
  const handleBacklogReorder = useCallback(async (itemId: number, pointerY: number) => {
796
108
  if (!onOrderChangeRef.current) return;
797
109
 
798
- // Get current backlog item IDs from ref
799
- const backlogItemIds = new Set<number>();
110
+ // Collect all backlog items (excluding dragged item) with their data
111
+ const backlogItems: WorkItem[] = [];
800
112
  for (const group of backlogRef.current.values()) {
801
113
  for (const item of group.items) {
802
- backlogItemIds.add(item.id);
114
+ if (item.id !== itemId) {
115
+ backlogItems.push(item);
116
+ }
803
117
  }
804
118
  }
805
119
 
806
- // Use cached card positions from registry, filtered to backlog items
120
+ if (backlogItems.length === 0) return;
121
+
122
+ // Read fresh positions from DOM (not stale cache)
807
123
  const allPositions = getCardPositions();
124
+ const backlogItemIds = new Set(backlogItems.map(item => item.id));
808
125
  const cardPositions = allPositions
809
- .filter(pos => backlogItemIds.has(pos.id) && pos.id !== itemId)
126
+ .filter(pos => backlogItemIds.has(pos.id))
810
127
  .map(pos => ({
811
128
  id: pos.id,
812
129
  midY: (pos.rect.top + pos.rect.bottom) / 2,
813
- }));
130
+ }))
131
+ .sort((a, b) => a.midY - b.midY);
814
132
 
815
- // Sort by Y position
816
- cardPositions.sort((a, b) => a.midY - b.midY);
133
+ if (cardPositions.length === 0) return;
817
134
 
818
135
  // Find insertion index based on pointer Y
819
136
  let insertIndex = cardPositions.length;
@@ -824,9 +141,26 @@ function BacklogDropZoneWrapper({ backlog, onStatusChange, onOrderChange, childr
824
141
  }
825
142
  }
826
143
 
827
- // Calculate new display_order with bounds checking
828
- let newOrder = insertIndex * DISPLAY_ORDER_INCREMENT;
829
- // Clamp to safe bounds to prevent overflow
144
+ // Map visual positions to items for display_order midpoint calculation
145
+ const itemMap = new Map(backlogItems.map(item => [item.id, item]));
146
+ const visualOrder = cardPositions.map(pos => itemMap.get(pos.id)!).filter(Boolean);
147
+
148
+ // Calculate proper midpoint display_order between surrounding items
149
+ let newOrder: number;
150
+ if (visualOrder.length === 0) {
151
+ newOrder = DISPLAY_ORDER_INCREMENT;
152
+ } else if (insertIndex === 0) {
153
+ const firstOrder = visualOrder[0].display_order ?? visualOrder[0].id;
154
+ newOrder = firstOrder - DISPLAY_ORDER_INCREMENT;
155
+ } else if (insertIndex >= visualOrder.length) {
156
+ const lastOrder = visualOrder[visualOrder.length - 1].display_order ?? visualOrder[visualOrder.length - 1].id;
157
+ newOrder = lastOrder + DISPLAY_ORDER_INCREMENT;
158
+ } else {
159
+ const before = visualOrder[insertIndex - 1].display_order ?? visualOrder[insertIndex - 1].id;
160
+ const after = visualOrder[insertIndex].display_order ?? visualOrder[insertIndex].id;
161
+ newOrder = Math.floor((before + after) / 2);
162
+ }
163
+
830
164
  newOrder = Math.max(MIN_DISPLAY_ORDER, Math.min(MAX_DISPLAY_ORDER, newOrder));
831
165
 
832
166
  await onOrderChangeRef.current(itemId, newOrder);
@@ -840,7 +174,7 @@ function BacklogDropZoneWrapper({ backlog, onStatusChange, onOrderChange, childr
840
174
  }}
841
175
  onReorder={handleBacklogReorder}
842
176
  allowReorder={true}
843
- className="rounded-lg p-2 -m-2 min-h-[100px]"
177
+ className="rounded-lg p-3 -m-3 min-h-[100px]"
844
178
  highlightClassName="ring-2 ring-amber-400 bg-amber-100/50 dark:bg-amber-900/30"
845
179
  reorderHighlightClassName="ring-2 ring-purple-400 bg-purple-100/50 dark:bg-purple-900/30"
846
180
  data-testid="backlog-drop-zone"
@@ -857,11 +191,13 @@ interface KanbanBoardProps {
857
191
  onTitleSave?: (id: number, newTitle: string) => Promise<void>;
858
192
  onStatusChange?: (id: number, newStatus: string) => Promise<void | { success: boolean; notFound?: boolean }>;
859
193
  onReject?: (id: number, reason: string) => Promise<void>;
194
+ onRestart?: (id: number) => void;
860
195
  onOrderChange?: (id: number, newOrder: number) => Promise<void>;
861
196
  onEpicAssign?: (id: number, epicId: number | null) => Promise<void>;
862
- onTriggerClaude?: (id: number, title: string, type: string) => void;
197
+ onTriggerClaude?: (id: number, title: string, type: string, conversational?: boolean, description?: string | null) => void;
863
198
  // Multi-session support
864
199
  onOpenSession?: (id: string) => void;
200
+ onCloseSession?: (id: string) => void;
865
201
  activeSessions?: Map<string, Session>;
866
202
  // Undo/redo support
867
203
  onUndo?: () => Promise<UndoAction | null>;
@@ -872,15 +208,27 @@ interface KanbanBoardProps {
872
208
  onError?: (message: string) => void;
873
209
  // Add to backlog
874
210
  onAddToBacklog?: () => void;
211
+ // Usage limits
212
+ usageAllowed?: boolean;
875
213
  // External animation trigger (e.g., from CLI/DB completions detected via WebSocket)
876
214
  externalAnimatingItemId?: number | null;
877
215
  onExternalAnimationComplete?: () => void;
878
216
  }
879
217
 
880
- export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChange, onReject, onOrderChange, onEpicAssign, onTriggerClaude, onOpenSession, activeSessions, onUndo, onRedo, canUndo, canRedo, onError, onAddToBacklog, externalAnimatingItemId, onExternalAnimationComplete }: KanbanBoardProps) {
218
+ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChange, onReject, onRestart, onOrderChange, onEpicAssign, onTriggerClaude, onOpenSession, onCloseSession, activeSessions, onUndo, onRedo, canUndo, canRedo, onError, onAddToBacklog, usageAllowed = true, externalAnimatingItemId, onExternalAnimationComplete }: KanbanBoardProps) {
881
219
  const backlogCount = inFlight.length + Array.from(backlog.values()).reduce((sum, g) => sum + g.items.length, 0);
882
220
  const doneCount = Array.from(done.values()).reduce((sum, g) => sum + g.items.length, 0);
883
221
 
222
+ // Lazy loading state for backlog and done columns
223
+ const [showAllBacklog, setShowAllBacklog] = useState(false);
224
+ const [showAllDone, setShowAllDone] = useState(false);
225
+
226
+ const backlogEntries = useMemo(() => Array.from(backlog.entries()), [backlog]);
227
+ const doneEntries = useMemo(() => Array.from(done.entries()), [done]);
228
+ const backlogLimit = Math.max(0, BACKLOG_VISIBLE_LIMIT - inFlight.length);
229
+ const { visible: visibleBacklog, hasMore: hasMoreBacklog } = getVisibleEntries(backlogEntries, backlogLimit, showAllBacklog);
230
+ const { visible: visibleDone, hasMore: hasMoreDone } = getVisibleEntries(doneEntries, DONE_VISIBLE_LIMIT, showAllDone);
231
+
884
232
  // Keyboard shortcuts for undo/redo (Cmd+Z / Cmd+Shift+Z)
885
233
  useEffect(() => {
886
234
  const handleKeyDown = async (e: KeyboardEvent) => {
@@ -913,12 +261,17 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
913
261
  return () => document.removeEventListener('keydown', handleKeyDown);
914
262
  }, [onUndo, onRedo, canUndo, canRedo]);
915
263
 
916
- // Build a set of epic IDs that have in-flight items
917
- const inFlightEpicIds = new Set<number>();
264
+ // Build a map of epic IDs to their in-flight items
265
+ const inFlightByEpic = new Map<number, InFlightItem[]>();
918
266
  for (const item of inFlight) {
919
267
  const epicId = item.parent_id || item.epic_id;
920
268
  if (epicId) {
921
- inFlightEpicIds.add(epicId);
269
+ const existing = inFlightByEpic.get(epicId);
270
+ if (existing) {
271
+ existing.push(item);
272
+ } else {
273
+ inFlightByEpic.set(epicId, [item]);
274
+ }
922
275
  }
923
276
  }
924
277
 
@@ -1002,24 +355,24 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
1002
355
  <DragProvider renderDragOverlay={renderDragOverlay} onRemoveFromEpic={onEpicAssign} onError={onError}>
1003
356
  <div className="flex gap-4 overflow-x-auto h-full" data-testid="kanban-board">
1004
357
  {/* Backlog Column */}
1005
- <KanbanColumn title="Backlog" count={backlogCount} onAdd={onAddToBacklog}>
358
+ <KanbanColumn title="Backlog" count={backlogCount} onAdd={onAddToBacklog} addDisabled={!usageAllowed}>
1006
359
  {/* In Flight Section - Drop Zone */}
1007
360
  <DropZone
1008
361
  targetStatus="in_progress"
1009
362
  onDrop={async (itemId, newStatus) => {
1010
363
  await handleStatusChangeWithAnimation(itemId, newStatus);
1011
364
  }}
1012
- className="rounded-lg mb-4 p-2 -m-2"
1013
- highlightClassName="ring-2 ring-blue-400 bg-blue-100/50 dark:bg-blue-900/30"
365
+ className="rounded-lg mb-6 p-3 -m-3"
366
+ highlightClassName="ring-2 ring-[#819D9F] bg-[#819D9F]/10 dark:bg-[#819D9F]/20"
1014
367
  data-testid="in-flight-drop-zone"
1015
368
  >
1016
369
  {inFlight.length > 0 ? (
1017
- <div data-testid="in-flight-section">
1018
- <div className="flex items-center gap-1.5 text-xs font-medium text-blue-600 dark:text-blue-400 mb-2">
1019
- <span>🔥</span>
370
+ <div data-testid="in-flight-section" className="bg-[#e8f0f0] dark:bg-[#819D9F]/20 rounded-lg p-3 -m-1">
371
+ <div className="flex items-center gap-2 text-base font-medium text-[#5a7d7f] dark:text-[#a3bfc0] mb-3">
372
+ <img src="/in-flight-seagull.svg" alt="" className="w-5 h-5" />
1020
373
  <span>In Flight</span>
1021
374
  </div>
1022
- <div className="space-y-2">
375
+ <div className="space-y-3">
1023
376
  {inFlight.map((item) => (
1024
377
  <DraggableCard key={item.id} item={item}>
1025
378
  <KanbanCard
@@ -1030,9 +383,12 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
1030
383
  onTitleSave={onTitleSave}
1031
384
  onStatusChange={handleStatusChangeWithAnimation}
1032
385
  onReject={onReject}
386
+ onRestart={onRestart}
1033
387
  onTriggerClaude={onTriggerClaude}
1034
388
  hasActiveSession={activeSessions?.has(String(item.id))}
1035
389
  onOpenSession={onOpenSession}
390
+ onCloseSession={onCloseSession}
391
+ usageAllowed={usageAllowed}
1036
392
  isCompletingAnimation={animatingItemId === item.id}
1037
393
  onAnimationComplete={handleAnimationComplete}
1038
394
  />
@@ -1041,8 +397,8 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
1041
397
  </div>
1042
398
  </div>
1043
399
  ) : (
1044
- <div className="flex items-center gap-1.5 text-xs font-medium text-zinc-400 dark:text-zinc-500 py-2">
1045
- <span>🔥</span>
400
+ <div className="flex items-center gap-2 text-base font-medium text-zinc-400 dark:text-zinc-500 py-3">
401
+ <img src="/in-flight-seagull.svg" alt="" className="w-5 h-5 opacity-50" />
1046
402
  <span>Drop here to start work</span>
1047
403
  </div>
1048
404
  )}
@@ -1050,7 +406,7 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
1050
406
 
1051
407
  {/* Divider if both sections have content */}
1052
408
  {(inFlight.length > 0 || backlog.size > 0) && (
1053
- <hr className="border-zinc-300 dark:border-zinc-700 my-4" />
409
+ <hr className="border-zinc-300 dark:border-zinc-700 my-6" />
1054
410
  )}
1055
411
 
1056
412
  {/* Backlog Section - Drop Zone with Reordering */}
@@ -1060,36 +416,50 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
1060
416
  onOrderChange={onOrderChange}
1061
417
  >
1062
418
  <div>
1063
- {/* Grouped Backlog Items */}
1064
- {Array.from(backlog.entries()).map(([key, group]) => (
419
+ {/* Grouped Backlog Items (lazy loaded) */}
420
+ {visibleBacklog.map(([key, group]) => (
1065
421
  <EpicGroup
1066
422
  key={key}
1067
423
  epicId={group.epicId}
1068
424
  epicTitle={group.epicTitle}
1069
425
  items={group.items}
1070
- isInFlight={group.epicId ? inFlightEpicIds.has(group.epicId) : false}
426
+ isInFlight={group.epicId ? inFlightByEpic.has(group.epicId) : false}
427
+ inFlightItems={group.epicId ? inFlightByEpic.get(group.epicId) : undefined}
1071
428
  onTitleSave={onTitleSave}
1072
429
  onStatusChange={handleStatusChangeWithAnimation}
1073
430
  onReject={onReject}
431
+ onRestart={onRestart}
1074
432
  onEpicAssign={onEpicAssign}
1075
433
  onOrderChange={onOrderChange}
1076
434
  onTriggerClaude={onTriggerClaude}
1077
435
  activeSessions={activeSessions}
1078
436
  onOpenSession={onOpenSession}
437
+ onCloseSession={onCloseSession}
1079
438
  onError={onError}
439
+ usageAllowed={usageAllowed}
1080
440
  animatingItemId={animatingItemId}
1081
441
  onAnimationComplete={handleAnimationComplete}
1082
442
  />
1083
443
  ))}
1084
444
 
445
+ {hasMoreBacklog && (
446
+ <button
447
+ onClick={() => setShowAllBacklog(true)}
448
+ className="w-full mt-3 py-2 text-sm text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300 hover:bg-zinc-200/50 dark:hover:bg-zinc-800/50 rounded-lg transition-colors duration-200 ease-out"
449
+ data-testid="backlog-show-more"
450
+ >
451
+ Show more
452
+ </button>
453
+ )}
454
+
1085
455
  {backlog.size === 0 && (
1086
- <p className="text-sm text-zinc-500 text-center py-4">Drop items here for backlog</p>
456
+ <p className="text-base text-zinc-500 text-center py-4">Drop items here for backlog</p>
1087
457
  )}
1088
458
  </div>
1089
459
  </BacklogDropZoneWrapper>
1090
460
 
1091
461
  {backlogCount === 0 && inFlight.length === 0 && (
1092
- <p className="text-sm text-zinc-500 text-center py-4">No items in backlog</p>
462
+ <p className="text-base text-zinc-500 text-center py-4">No items in backlog</p>
1093
463
  )}
1094
464
  </KanbanColumn>
1095
465
 
@@ -1100,11 +470,11 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
1100
470
  onDrop={async (itemId, newStatus) => {
1101
471
  await handleStatusChangeWithAnimation(itemId, newStatus);
1102
472
  }}
1103
- className="rounded-lg p-2 -m-2 min-h-[100px]"
1104
- highlightClassName="ring-2 ring-green-400 bg-green-100/50 dark:bg-green-900/30"
473
+ className="rounded-lg p-3 -m-3 min-h-[100px]"
474
+ highlightClassName="ring-2 ring-zinc-400 bg-zinc-100/50 dark:bg-zinc-800/50"
1105
475
  data-testid="done-drop-zone"
1106
476
  >
1107
- {Array.from(done.entries()).map(([key, group]) => (
477
+ {visibleDone.map(([key, group]) => (
1108
478
  <EpicGroup
1109
479
  key={key}
1110
480
  epicId={group.epicId}
@@ -1114,15 +484,26 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
1114
484
  onTitleSave={onTitleSave}
1115
485
  onStatusChange={handleStatusChangeWithAnimation}
1116
486
  onReject={onReject}
1117
- onEpicAssign={onEpicAssign}
1118
487
  activeSessions={activeSessions}
1119
488
  onOpenSession={onOpenSession}
489
+ onCloseSession={onCloseSession}
1120
490
  onError={onError}
491
+ usageAllowed={usageAllowed}
1121
492
  />
1122
493
  ))}
1123
494
 
495
+ {hasMoreDone && (
496
+ <button
497
+ onClick={() => setShowAllDone(true)}
498
+ className="w-full mt-3 py-2 text-sm text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300 hover:bg-zinc-200/50 dark:hover:bg-zinc-800/50 rounded-lg transition-colors duration-200 ease-out"
499
+ data-testid="done-show-more"
500
+ >
501
+ Show more
502
+ </button>
503
+ )}
504
+
1124
505
  {doneCount === 0 && (
1125
- <p className="text-sm text-zinc-500 text-center py-4">Drop here to mark complete</p>
506
+ <p className="text-base text-zinc-500 text-center py-4">Drop here to mark complete</p>
1126
507
  )}
1127
508
  </DropZone>
1128
509
  </KanbanColumn>