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.
- package/.env +7 -0
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +124 -48
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +171 -58
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +161 -10
- package/apps/dashboard/app/api/tests/run/stream/route.ts +13 -1
- package/apps/dashboard/app/api/usage/route.ts +17 -0
- package/apps/dashboard/app/api/work/[id]/route.ts +35 -0
- package/apps/dashboard/app/api/work/[id]/status/route.ts +43 -1
- package/apps/dashboard/app/connect-claude/page.tsx +24 -0
- package/apps/dashboard/app/decision/[id]/page.tsx +14 -14
- package/apps/dashboard/app/demo/gates/page.tsx +42 -42
- package/apps/dashboard/app/design-system/page.tsx +868 -0
- package/apps/dashboard/app/globals.css +6 -2
- package/apps/dashboard/app/install-claude/page.tsx +9 -7
- package/apps/dashboard/app/layout.tsx +17 -5
- package/apps/dashboard/app/login/page.tsx +250 -0
- package/apps/dashboard/app/page.tsx +11 -9
- package/apps/dashboard/app/settings/page.tsx +4 -2
- package/apps/dashboard/app/signup/page.tsx +245 -0
- package/apps/dashboard/app/subscribe/page.tsx +11 -0
- package/apps/dashboard/app/welcome/page.tsx +24 -1
- package/apps/dashboard/app/work/[id]/page.tsx +34 -50
- package/apps/dashboard/components/AppShell.tsx +95 -55
- package/apps/dashboard/components/CardMenu.tsx +56 -13
- package/apps/dashboard/components/ClaudePanel.tsx +301 -582
- package/apps/dashboard/components/ClaudePanelInput.tsx +23 -14
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +210 -0
- package/apps/dashboard/components/CopyableId.tsx +3 -3
- package/apps/dashboard/components/DetailReviewActions.tsx +109 -0
- package/apps/dashboard/components/DragContext.tsx +75 -65
- package/apps/dashboard/components/DraggableCard.tsx +6 -46
- package/apps/dashboard/components/DropZone.tsx +2 -2
- package/apps/dashboard/components/EditableDetailDescription.tsx +1 -1
- package/apps/dashboard/components/EditableTitle.tsx +26 -6
- package/apps/dashboard/components/ElapsedTimer.tsx +54 -0
- package/apps/dashboard/components/EpicGroup.tsx +329 -0
- package/apps/dashboard/components/GateCard.tsx +100 -16
- package/apps/dashboard/components/GateChoiceCard.tsx +15 -17
- package/apps/dashboard/components/InstallClaudeScreen.tsx +140 -51
- package/apps/dashboard/components/JettyLoader.tsx +38 -0
- package/apps/dashboard/components/KanbanBoard.tsx +147 -766
- package/apps/dashboard/components/KanbanCard.tsx +506 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +12 -0
- package/apps/dashboard/components/MainNav.tsx +20 -54
- package/apps/dashboard/components/MessageBlock.tsx +391 -0
- package/apps/dashboard/components/ModeStartCard.tsx +15 -15
- package/apps/dashboard/components/OnboardingWelcome.tsx +214 -0
- package/apps/dashboard/components/PlaceholderCard.tsx +11 -21
- package/apps/dashboard/components/ProjectSwitcher.tsx +36 -8
- package/apps/dashboard/components/PrototypeTimeline.tsx +25 -25
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +265 -301
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +97 -74
- package/apps/dashboard/components/ReviewFooter.tsx +141 -0
- package/apps/dashboard/components/SessionList.tsx +19 -18
- package/apps/dashboard/components/SubscribeContent.tsx +206 -0
- package/apps/dashboard/components/TestTree.tsx +15 -14
- package/apps/dashboard/components/TipCard.tsx +177 -0
- package/apps/dashboard/components/Toast.tsx +5 -5
- package/apps/dashboard/components/TypeIcon.tsx +56 -0
- package/apps/dashboard/components/UpgradeBanner.tsx +30 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +61 -62
- package/apps/dashboard/components/WelcomeScreen.tsx +25 -27
- package/apps/dashboard/components/WorkItemHeader.tsx +4 -4
- package/apps/dashboard/components/WorkItemTree.tsx +9 -28
- package/apps/dashboard/components/settings/AccountSection.tsx +169 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +54 -79
- package/apps/dashboard/components/settings/GeneralSection.tsx +26 -31
- package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -4
- package/apps/dashboard/components/ui/Button.tsx +104 -0
- package/apps/dashboard/components/ui/Input.tsx +78 -0
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +408 -105
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -4
- package/apps/dashboard/contexts/UsageContext.tsx +155 -0
- package/apps/dashboard/contexts/usageHelpers.js +9 -0
- package/apps/dashboard/electron/ipc-handlers.js +281 -88
- package/apps/dashboard/electron/main.js +691 -131
- package/apps/dashboard/electron/preload.js +25 -4
- package/apps/dashboard/electron/session-manager.js +163 -0
- package/apps/dashboard/electron-builder.config.js +3 -5
- package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
- package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
- package/apps/dashboard/lib/backlog-parser.ts +50 -0
- package/apps/dashboard/lib/claude-process-manager.ts +50 -11
- package/apps/dashboard/lib/constants.ts +43 -0
- package/apps/dashboard/lib/db-bridge.ts +33 -0
- package/apps/dashboard/lib/db.ts +136 -20
- package/apps/dashboard/lib/kanban-utils.ts +70 -0
- package/apps/dashboard/lib/run-migrations.js +27 -2
- package/apps/dashboard/lib/session-state-machine.ts +3 -0
- package/apps/dashboard/lib/session-stream-manager.ts +144 -38
- package/apps/dashboard/lib/shadows.ts +7 -0
- package/apps/dashboard/lib/tests.ts +3 -1
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next.config.js +35 -14
- package/apps/dashboard/package.json +6 -3
- package/apps/dashboard/public/bug-icon.svg +9 -0
- package/apps/dashboard/public/buoy-icon.svg +9 -0
- package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
- package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
- package/apps/dashboard/public/in-flight-seagull.svg +9 -0
- package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
- package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
- package/apps/dashboard/public/jettypod_logo.png +0 -0
- package/apps/dashboard/public/pier-icon.svg +14 -0
- package/apps/dashboard/public/star-icon.svg +9 -0
- package/apps/dashboard/public/wrench-icon.svg +9 -0
- package/apps/dashboard/scripts/upload-to-r2.js +89 -0
- package/apps/dashboard/scripts/ws-server.js +191 -0
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
- package/apps/update-server/package.json +16 -0
- package/apps/update-server/schema.sql +31 -0
- package/apps/update-server/src/index.ts +1085 -0
- package/apps/update-server/tsconfig.json +16 -0
- package/apps/update-server/wrangler.toml +35 -0
- package/cucumber.js +9 -3
- package/docs/COMMAND_REFERENCE.md +34 -0
- package/hooks/post-checkout +32 -75
- package/hooks/post-merge +111 -10
- package/jest.setup.js +1 -0
- package/jettypod.js +54 -116
- package/lib/chore-taxonomy.js +33 -10
- package/lib/database.js +36 -16
- package/lib/db-watcher.js +1 -1
- package/lib/git-hooks/pre-commit +1 -1
- package/lib/jettypod-backup.js +27 -4
- package/lib/migrations/027-plan-at-creation-column.js +33 -0
- package/lib/migrations/028-ready-for-review-column.js +27 -0
- package/lib/migrations/029-remove-autoincrement.js +307 -0
- package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
- package/lib/migrations/index.js +47 -4
- package/lib/schema.js +13 -6
- package/lib/seed-onboarding.js +101 -69
- package/lib/update-command/index.js +9 -175
- package/lib/work-commands/index.js +129 -16
- package/lib/work-tracking/index.js +86 -46
- package/lib/worktree-diagnostics.js +16 -16
- package/lib/worktree-facade.js +1 -1
- package/lib/worktree-manager.js +8 -8
- package/lib/worktree-reconciler.js +5 -5
- package/package.json +9 -2
- package/scripts/ndjson-to-cucumber-json.js +152 -0
- package/scripts/postinstall.js +25 -0
- package/skills-templates/bug-mode/SKILL.md +39 -28
- package/skills-templates/bug-planning/SKILL.md +25 -29
- package/skills-templates/chore-mode/SKILL.md +131 -68
- package/skills-templates/chore-mode/verification.js +51 -10
- package/skills-templates/chore-planning/SKILL.md +47 -18
- package/skills-templates/epic-planning/SKILL.md +68 -48
- package/skills-templates/external-transition/SKILL.md +47 -47
- package/skills-templates/feature-planning/SKILL.md +83 -73
- package/skills-templates/production-mode/SKILL.md +49 -49
- package/skills-templates/request-routing/SKILL.md +27 -14
- package/skills-templates/simple-improvement/SKILL.md +68 -44
- package/skills-templates/speed-mode/SKILL.md +209 -128
- package/skills-templates/stable-mode/SKILL.md +105 -94
- package/templates/bdd-guidance.md +139 -0
- package/templates/bdd-scaffolding/wait.js +18 -0
- package/templates/bdd-scaffolding/world.js +19 -0
- package/.jettypod-backup/work.db +0 -0
- package/apps/dashboard/app/access-code/page.tsx +0 -110
- package/lib/discovery-checkpoint.js +0 -123
- 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 {
|
|
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 {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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
|
-
//
|
|
799
|
-
const
|
|
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
|
-
|
|
114
|
+
if (item.id !== itemId) {
|
|
115
|
+
backlogItems.push(item);
|
|
116
|
+
}
|
|
803
117
|
}
|
|
804
118
|
}
|
|
805
119
|
|
|
806
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
//
|
|
828
|
-
|
|
829
|
-
|
|
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-
|
|
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
|
|
917
|
-
const
|
|
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
|
-
|
|
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-
|
|
1013
|
-
highlightClassName="ring-2 ring-
|
|
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-
|
|
1019
|
-
<
|
|
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-
|
|
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-
|
|
1045
|
-
<
|
|
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-
|
|
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
|
-
{
|
|
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 ?
|
|
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-
|
|
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-
|
|
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-
|
|
1104
|
-
highlightClassName="ring-2 ring-
|
|
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
|
-
{
|
|
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-
|
|
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>
|