jettypod 4.4.118 → 4.4.121
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env +4 -3
- package/Cargo.lock +6450 -0
- package/Cargo.toml +35 -0
- package/README.md +5 -1
- package/TAURI-MIGRATION-PLAN.md +840 -0
- package/apps/dashboard/app/connect-claude/page.tsx +5 -6
- package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
- package/apps/dashboard/app/demo/gates/page.tsx +43 -45
- package/apps/dashboard/app/design-system/page.tsx +868 -0
- package/apps/dashboard/app/globals.css +80 -4
- package/apps/dashboard/app/install-claude/page.tsx +4 -6
- package/apps/dashboard/app/login/page.tsx +72 -54
- package/apps/dashboard/app/page.tsx +101 -48
- package/apps/dashboard/app/settings/page.tsx +61 -13
- package/apps/dashboard/app/signup/page.tsx +242 -0
- package/apps/dashboard/app/subscribe/page.tsx +0 -2
- package/apps/dashboard/app/tests/page.tsx +37 -4
- package/apps/dashboard/app/welcome/page.tsx +13 -16
- package/apps/dashboard/app/work/[id]/page.tsx +117 -118
- package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
- package/apps/dashboard/components/AppShell.tsx +92 -85
- package/apps/dashboard/components/CardMenu.tsx +45 -12
- package/apps/dashboard/components/ClaudePanel.tsx +771 -850
- package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
- package/apps/dashboard/components/CopyableId.tsx +3 -4
- package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
- package/apps/dashboard/components/DragContext.tsx +134 -63
- package/apps/dashboard/components/DraggableCard.tsx +3 -5
- package/apps/dashboard/components/DropZone.tsx +6 -7
- package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
- package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
- package/apps/dashboard/components/EditableTitle.tsx +26 -7
- package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
- package/apps/dashboard/components/EpicGroup.tsx +359 -0
- package/apps/dashboard/components/GateCard.tsx +79 -17
- package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
- package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
- package/apps/dashboard/components/JettyLoader.tsx +37 -0
- package/apps/dashboard/components/KanbanBoard.tsx +368 -958
- package/apps/dashboard/components/KanbanCard.tsx +740 -0
- package/apps/dashboard/components/LazyCard.tsx +62 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
- package/apps/dashboard/components/MainNav.tsx +38 -73
- package/apps/dashboard/components/MessageBlock.tsx +468 -0
- package/apps/dashboard/components/ModeStartCard.tsx +15 -16
- package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
- package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
- package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
- package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
- package/apps/dashboard/components/ReviewFooter.tsx +139 -0
- package/apps/dashboard/components/SessionList.tsx +19 -19
- package/apps/dashboard/components/SubscribeContent.tsx +91 -47
- package/apps/dashboard/components/TestTree.tsx +16 -16
- package/apps/dashboard/components/TipCard.tsx +16 -17
- package/apps/dashboard/components/Toast.tsx +5 -6
- package/apps/dashboard/components/TypeIcon.tsx +55 -0
- package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
- package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
- package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
- package/apps/dashboard/components/WorkItemTree.tsx +11 -32
- package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
- package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
- package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
- package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
- package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
- package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
- package/apps/dashboard/components/ui/Button.tsx +104 -0
- package/apps/dashboard/components/ui/Input.tsx +78 -0
- package/apps/dashboard/components.json +1 -1
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
- package/apps/dashboard/contexts/UsageContext.tsx +87 -32
- package/apps/dashboard/dev.sh +35 -0
- package/apps/dashboard/eslint.config.mjs +9 -9
- package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
- package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
- package/apps/dashboard/hooks/useWebSocket.ts +138 -83
- package/apps/dashboard/index.html +73 -0
- package/apps/dashboard/lib/constants.ts +43 -0
- package/apps/dashboard/lib/data-bridge.ts +722 -0
- package/apps/dashboard/lib/db.ts +69 -1265
- package/apps/dashboard/lib/environment-config.ts +173 -0
- package/apps/dashboard/lib/environment-verification.ts +119 -0
- package/apps/dashboard/lib/kanban-utils.ts +270 -0
- package/apps/dashboard/lib/proof-run.ts +495 -0
- package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
- package/apps/dashboard/lib/run-migrations.js +27 -2
- package/apps/dashboard/lib/service-recovery.ts +326 -0
- package/apps/dashboard/lib/session-state-machine.ts +1 -0
- package/apps/dashboard/lib/session-state-utils.ts +0 -164
- package/apps/dashboard/lib/session-stream-manager.ts +308 -134
- package/apps/dashboard/lib/shadows.ts +7 -0
- package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
- package/apps/dashboard/lib/tauri-bridge.ts +102 -0
- package/apps/dashboard/lib/tauri.ts +106 -0
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next-env.d.ts +1 -1
- package/apps/dashboard/package.json +21 -32
- package/apps/dashboard/public/bug-icon.png +0 -0
- package/apps/dashboard/public/buoy-icon.png +0 -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.png +0 -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.png +0 -0
- package/apps/dashboard/public/star-icon.png +0 -0
- package/apps/dashboard/public/wrench-icon.png +0 -0
- package/apps/dashboard/scripts/tauri-build.js +228 -0
- package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
- package/apps/dashboard/scripts/ws-server.js +191 -0
- package/apps/dashboard/src/main.tsx +12 -0
- package/apps/dashboard/src/router.tsx +107 -0
- package/apps/dashboard/src/vite-env.d.ts +1 -0
- package/apps/dashboard/tsconfig.json +7 -12
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
- package/apps/dashboard/vite.config.ts +33 -0
- package/apps/update-server/src/index.ts +228 -80
- package/claude-hooks/global-guardrails.js +14 -13
- package/crates/jettypod-cli/Cargo.toml +19 -0
- package/crates/jettypod-cli/src/commands.rs +1249 -0
- package/crates/jettypod-cli/src/main.rs +595 -0
- package/crates/jettypod-core/Cargo.toml +26 -0
- package/crates/jettypod-core/build.rs +98 -0
- package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
- package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
- package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
- package/crates/jettypod-core/src/auth.rs +294 -0
- package/crates/jettypod-core/src/config.rs +397 -0
- package/crates/jettypod-core/src/db/mod.rs +507 -0
- package/crates/jettypod-core/src/db/recovery.rs +114 -0
- package/crates/jettypod-core/src/db/startup.rs +101 -0
- package/crates/jettypod-core/src/db/validate.rs +149 -0
- package/crates/jettypod-core/src/error.rs +76 -0
- package/crates/jettypod-core/src/git.rs +458 -0
- package/crates/jettypod-core/src/lib.rs +20 -0
- package/crates/jettypod-core/src/sessions.rs +625 -0
- package/crates/jettypod-core/src/skills.rs +556 -0
- package/crates/jettypod-core/src/work.rs +1086 -0
- package/crates/jettypod-core/src/worktree.rs +628 -0
- package/crates/jettypod-core/src/ws.rs +767 -0
- package/cucumber-test.cjs +6 -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 +145 -116
- package/lib/bdd-preflight.js +96 -0
- 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/merge-lock.js +111 -253
- package/lib/migrations/027-plan-at-creation-column.js +3 -1
- package/lib/migrations/029-remove-autoincrement.js +307 -0
- package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
- package/lib/migrations/030-rejection-round-columns.js +54 -0
- package/lib/migrations/031-session-isolation-index.js +17 -0
- package/lib/migrations/index.js +47 -4
- package/lib/schema.js +10 -5
- package/lib/seed-onboarding.js +1 -1
- package/lib/update-command/index.js +9 -175
- package/lib/work-commands/index.js +144 -19
- package/lib/work-tracking/index.js +148 -27
- 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 +79 -20
- package/skills-templates/bug-planning/SKILL.md +25 -29
- package/skills-templates/chore-mode/SKILL.md +171 -69
- package/skills-templates/chore-mode/verification.js +51 -10
- package/skills-templates/chore-planning/SKILL.md +47 -18
- package/skills-templates/design-system-selection/SKILL.md +273 -0
- package/skills-templates/epic-planning/SKILL.md +82 -48
- package/skills-templates/external-transition/SKILL.md +47 -47
- package/skills-templates/feature-planning/SKILL.md +173 -74
- package/skills-templates/production-mode/SKILL.md +69 -49
- package/skills-templates/request-routing/SKILL.md +4 -4
- package/skills-templates/simple-improvement/SKILL.md +74 -29
- package/skills-templates/speed-mode/SKILL.md +217 -141
- package/skills-templates/stable-mode/SKILL.md +148 -89
- package/apps/dashboard/README.md +0 -36
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
- package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
- package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
- package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
- package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
- package/apps/dashboard/app/api/kanban/route.ts +0 -15
- package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
- package/apps/dashboard/app/api/settings/general/route.ts +0 -21
- package/apps/dashboard/app/api/tests/route.ts +0 -9
- package/apps/dashboard/app/api/tests/run/route.ts +0 -82
- package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
- package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
- package/apps/dashboard/app/api/usage/route.ts +0 -17
- package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
- package/apps/dashboard/app/layout.tsx +0 -43
- package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
- package/apps/dashboard/electron/ipc-handlers.js +0 -1028
- package/apps/dashboard/electron/main.js +0 -2124
- package/apps/dashboard/electron/preload.js +0 -123
- package/apps/dashboard/electron/session-manager.js +0 -141
- package/apps/dashboard/electron-builder.config.js +0 -357
- package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
- package/apps/dashboard/lib/claude-process-manager.ts +0 -492
- package/apps/dashboard/lib/db-bridge.ts +0 -282
- package/apps/dashboard/lib/prototypes.ts +0 -202
- package/apps/dashboard/lib/test-results-db.ts +0 -307
- package/apps/dashboard/lib/tests.ts +0 -282
- package/apps/dashboard/next.config.js +0 -50
- package/apps/dashboard/postcss.config.mjs +0 -7
- package/apps/dashboard/public/file.svg +0 -1
- package/apps/dashboard/public/globe.svg +0 -1
- package/apps/dashboard/public/next.svg +0 -1
- package/apps/dashboard/public/vercel.svg +0 -1
- package/apps/dashboard/public/window.svg +0 -1
- package/apps/dashboard/scripts/download-node.js +0 -104
- package/apps/dashboard/scripts/upload-to-r2.js +0 -89
- package/docs/bdd-guidance.md +0 -390
|
@@ -1,807 +1,38 @@
|
|
|
1
|
-
'use client';
|
|
2
1
|
|
|
3
|
-
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
|
4
|
-
import
|
|
5
|
-
import { useRouter } from 'next/navigation';
|
|
6
|
-
import { AnimatePresence, motion } from 'framer-motion';
|
|
7
|
-
import { useDroppable } from '@dnd-kit/core';
|
|
2
|
+
import { useState, useCallback, useRef, useEffect, useMemo, memo } from 'react';
|
|
3
|
+
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
8
4
|
import type { WorkItem, InFlightItem, KanbanGroup } from '@/lib/db';
|
|
9
5
|
import type { UndoAction } from '@/lib/undoStack';
|
|
10
|
-
import
|
|
11
|
-
import { EditableTitle } from './EditableTitle';
|
|
12
|
-
import { CardMenu } from './CardMenu';
|
|
13
|
-
import { DragProvider, useDragContext } from './DragContext';
|
|
6
|
+
import { DragProvider } from './DragContext';
|
|
14
7
|
import { DraggableCard } from './DraggableCard';
|
|
15
8
|
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}`;
|
|
9
|
+
import { KanbanCard } from './KanbanCard';
|
|
10
|
+
import { EpicGroup, MIN_DISPLAY_ORDER, MAX_DISPLAY_ORDER, DISPLAY_ORDER_INCREMENT } from './EpicGroup';
|
|
11
|
+
import { useDragContext } from './DragContext';
|
|
12
|
+
import { shadow } from '@/lib/shadows';
|
|
13
|
+
|
|
14
|
+
const BACKLOG_VISIBLE_LIMIT = 45;
|
|
15
|
+
const DONE_VISIBLE_LIMIT = 15;
|
|
16
|
+
|
|
17
|
+
// Returns the subset of entries to render, respecting soft epic-group boundaries.
|
|
18
|
+
// If adding a group would cross the limit but the count before it was under, include it fully.
|
|
19
|
+
function getVisibleEntries(
|
|
20
|
+
entries: [string, KanbanGroup][],
|
|
21
|
+
limit: number,
|
|
22
|
+
showAll: boolean
|
|
23
|
+
): { visible: [string, KanbanGroup][]; totalCount: number; hasMore: boolean } {
|
|
24
|
+
const totalCount = entries.reduce((sum, [, g]) => sum + g.items.length, 0);
|
|
25
|
+
if (showAll || totalCount <= limit) {
|
|
26
|
+
return { visible: entries, totalCount, hasMore: false };
|
|
38
27
|
}
|
|
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, conversational?: boolean, description?: string | null) => void;
|
|
61
|
-
hasActiveSession?: boolean;
|
|
62
|
-
onOpenSession?: (id: string) => void;
|
|
63
|
-
usageAllowed?: boolean;
|
|
64
|
-
// Animation state lifted to board level
|
|
65
|
-
isCompletingAnimation?: boolean;
|
|
66
|
-
onAnimationComplete?: () => void;
|
|
67
|
-
isHighlighted?: boolean;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function KanbanCard({ item, epicTitle, showEpic = false, isInFlight = false, onTitleSave, onStatusChange, onReject, onTriggerClaude, hasActiveSession, onOpenSession, usageAllowed = true, isCompletingAnimation = false, onAnimationComplete, isHighlighted = false }: KanbanCardProps) {
|
|
71
|
-
const [expanded, setExpanded] = useState(false);
|
|
72
|
-
const [showRejectInput, setShowRejectInput] = useState(false);
|
|
73
|
-
const [rejectReason, setRejectReason] = useState('');
|
|
74
|
-
const router = useRouter();
|
|
75
|
-
|
|
76
|
-
const handleOpenSession = (e: React.MouseEvent) => {
|
|
77
|
-
e.stopPropagation(); // Prevent card navigation
|
|
78
|
-
if (onOpenSession) {
|
|
79
|
-
onOpenSession(String(item.id));
|
|
80
|
-
}
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
const handleStart = async (e: React.MouseEvent) => {
|
|
84
|
-
e.stopPropagation(); // Prevent card navigation
|
|
85
|
-
if (onStatusChange) {
|
|
86
|
-
await onStatusChange(item.id, 'in_progress');
|
|
87
|
-
if (onTriggerClaude) {
|
|
88
|
-
onTriggerClaude(item.id, item.title, item.type, !!item.conversational, item.description);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
const canStart = item.status === 'backlog' || item.status === 'cancelled';
|
|
94
|
-
|
|
95
|
-
// An item is reviewable when it has ready_for_review flag set
|
|
96
|
-
// This applies to kanban-visible items: features, standalone chores/bugs, and items under epics
|
|
97
|
-
const isReviewable = !!item.ready_for_review;
|
|
98
|
-
|
|
99
|
-
const handleAccept = async (e: React.MouseEvent) => {
|
|
100
|
-
e.stopPropagation();
|
|
101
|
-
if (onStatusChange) {
|
|
102
|
-
await onStatusChange(item.id, 'done');
|
|
103
|
-
}
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
const handleRejectClick = (e: React.MouseEvent) => {
|
|
107
|
-
e.stopPropagation();
|
|
108
|
-
setShowRejectInput(true);
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
const handleRejectConfirm = async (e: React.MouseEvent) => {
|
|
112
|
-
e.stopPropagation();
|
|
113
|
-
if (onReject && rejectReason.trim()) {
|
|
114
|
-
await onReject(item.id, rejectReason.trim());
|
|
115
|
-
setShowRejectInput(false);
|
|
116
|
-
setRejectReason('');
|
|
117
|
-
}
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
const handleRejectCancel = (e: React.MouseEvent) => {
|
|
121
|
-
e.stopPropagation();
|
|
122
|
-
setShowRejectInput(false);
|
|
123
|
-
setRejectReason('');
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
// Calculate chores for expandable section
|
|
127
|
-
const allChores = item.chores || [];
|
|
128
|
-
const incompleteChores = allChores.filter(c => c.status !== 'done');
|
|
129
|
-
const hasChores = allChores.length > 0;
|
|
130
|
-
const hasIncompleteChores = incompleteChores.length > 0;
|
|
131
|
-
|
|
132
|
-
// Calculate bugs for expandable section
|
|
133
|
-
const allBugs = item.bugs || [];
|
|
134
|
-
const incompleteBugs = allBugs.filter(b => b.status !== 'done');
|
|
135
|
-
const hasBugs = allBugs.length > 0;
|
|
136
|
-
|
|
137
|
-
const handleCardClick = () => {
|
|
138
|
-
router.push(`/work/${item.id}`);
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
const handleTitleSave = async (id: number, newTitle: string) => {
|
|
142
|
-
if (onTitleSave) {
|
|
143
|
-
await onTitleSave(id, newTitle);
|
|
144
|
-
}
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
// Status changes are now handled by the board-level wrapper that triggers animation
|
|
148
|
-
const handleStatusChange = async (id: number, newStatus: string) => {
|
|
149
|
-
if (onStatusChange) {
|
|
150
|
-
await onStatusChange(id, newStatus);
|
|
151
|
-
}
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
const isDone = item.status === 'done';
|
|
155
|
-
|
|
156
|
-
const getCardStyles = () => {
|
|
157
|
-
const baseElevation = `
|
|
158
|
-
0 1px 2px rgba(0, 0, 0, 0.03),
|
|
159
|
-
0 2px 4px rgba(0, 0, 0, 0.03),
|
|
160
|
-
0 4px 8px rgba(0, 0, 0, 0.02)
|
|
161
|
-
`;
|
|
162
|
-
const hoverElevation = `
|
|
163
|
-
0 2px 4px rgba(0, 0, 0, 0.04),
|
|
164
|
-
0 4px 8px rgba(0, 0, 0, 0.04),
|
|
165
|
-
0 8px 16px rgba(0, 0, 0, 0.03),
|
|
166
|
-
0 12px 24px rgba(129, 157, 159, 0.08)
|
|
167
|
-
`;
|
|
168
|
-
|
|
169
|
-
if (isDone) {
|
|
170
|
-
return {
|
|
171
|
-
className: 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800',
|
|
172
|
-
boxShadow: baseElevation,
|
|
173
|
-
hoverBoxShadow: hoverElevation,
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
if (isInFlight) {
|
|
177
|
-
return {
|
|
178
|
-
className: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800',
|
|
179
|
-
boxShadow: baseElevation,
|
|
180
|
-
hoverBoxShadow: hoverElevation,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
return {
|
|
184
|
-
className: 'bg-white dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700',
|
|
185
|
-
boxShadow: baseElevation,
|
|
186
|
-
hoverBoxShadow: hoverElevation,
|
|
187
|
-
};
|
|
188
|
-
};
|
|
189
|
-
|
|
190
|
-
const cardStyles = getCardStyles();
|
|
191
|
-
|
|
192
|
-
const cardContent = (
|
|
193
|
-
<WaveCompletionAnimation isPlaying={isCompletingAnimation} onComplete={onAnimationComplete || (() => {})}>
|
|
194
|
-
<div
|
|
195
|
-
className={`rounded-xl border transition-all duration-200 hover:-translate-y-0.5 ${cardStyles.className}`}
|
|
196
|
-
style={{ boxShadow: cardStyles.boxShadow }}
|
|
197
|
-
onMouseEnter={(e) => { e.currentTarget.style.boxShadow = cardStyles.hoverBoxShadow; }}
|
|
198
|
-
onMouseLeave={(e) => { e.currentTarget.style.boxShadow = cardStyles.boxShadow; }}
|
|
199
|
-
data-testid={`kanban-card-${item.id}`}>
|
|
200
|
-
<div
|
|
201
|
-
onClick={handleCardClick}
|
|
202
|
-
className="block p-3 cursor-pointer"
|
|
203
|
-
>
|
|
204
|
-
<div className="flex items-start gap-2">
|
|
205
|
-
<span className="text-sm flex-shrink-0">{typeIcons[item.type] || '📄'}</span>
|
|
206
|
-
<div className="flex-1 min-w-0">
|
|
207
|
-
{isDone ? (
|
|
208
|
-
/* Compact layout for done cards: ID and title inline, no mode badge */
|
|
209
|
-
<div className="flex items-start gap-2">
|
|
210
|
-
<CopyableId id={item.id} title={item.title} type={item.type} />
|
|
211
|
-
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
|
212
|
-
{item.title || <span className="text-zinc-400 italic">(Untitled)</span>}
|
|
213
|
-
</span>
|
|
214
|
-
</div>
|
|
215
|
-
) : (
|
|
216
|
-
/* Standard layout: ID + mode badge on line 1, title below */
|
|
217
|
-
<>
|
|
218
|
-
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
|
219
|
-
<CopyableId id={item.id} title={item.title} type={item.type} />
|
|
220
|
-
{item.mode && modeLabels[item.mode] && (
|
|
221
|
-
<span className={`text-xs px-1.5 py-0.5 rounded ${modeLabels[item.mode].color}`}>
|
|
222
|
-
{getModeLabel(item)}
|
|
223
|
-
</span>
|
|
224
|
-
)}
|
|
225
|
-
</div>
|
|
226
|
-
<EditableTitle
|
|
227
|
-
title={item.title}
|
|
228
|
-
itemId={item.id}
|
|
229
|
-
onSave={handleTitleSave}
|
|
230
|
-
/>
|
|
231
|
-
</>
|
|
232
|
-
)}
|
|
233
|
-
{showEpic && epicTitle && (
|
|
234
|
-
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1.5 flex items-center gap-1">
|
|
235
|
-
<span>🎯</span>
|
|
236
|
-
<span>{epicTitle}</span>
|
|
237
|
-
</p>
|
|
238
|
-
)}
|
|
239
|
-
</div>
|
|
240
|
-
<div className="flex items-center gap-1">
|
|
241
|
-
{/* Accept/Reject buttons - shown for reviewable top-level items */}
|
|
242
|
-
{isReviewable && item.status !== 'done' && onStatusChange && (
|
|
243
|
-
<button
|
|
244
|
-
onClick={handleAccept}
|
|
245
|
-
className="p-1 rounded hover:bg-green-100 dark:hover:bg-green-900/30 text-green-600 dark:text-green-400 transition-colors"
|
|
246
|
-
aria-label="Accept work item"
|
|
247
|
-
data-testid={`accept-button-${item.id}`}
|
|
248
|
-
title="Accept"
|
|
249
|
-
>
|
|
250
|
-
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
251
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
252
|
-
</svg>
|
|
253
|
-
</button>
|
|
254
|
-
)}
|
|
255
|
-
{isReviewable && onReject && (
|
|
256
|
-
<button
|
|
257
|
-
onClick={handleRejectClick}
|
|
258
|
-
className="p-1 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 transition-colors"
|
|
259
|
-
aria-label="Reject work item"
|
|
260
|
-
data-testid={`reject-button-${item.id}`}
|
|
261
|
-
title="Reject"
|
|
262
|
-
>
|
|
263
|
-
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
264
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
265
|
-
</svg>
|
|
266
|
-
</button>
|
|
267
|
-
)}
|
|
268
|
-
{/* Session indicator - clickable icon to reopen session */}
|
|
269
|
-
{hasActiveSession && (
|
|
270
|
-
<button
|
|
271
|
-
onClick={handleOpenSession}
|
|
272
|
-
className="p-1.5 rounded hover:bg-zinc-100 dark:hover:bg-zinc-700 text-blue-500 transition-colors"
|
|
273
|
-
aria-label="Open active session"
|
|
274
|
-
data-testid={`session-indicator-${item.id}`}
|
|
275
|
-
title="Open session"
|
|
276
|
-
>
|
|
277
|
-
<SessionIndicatorIcon className="w-4 h-4" />
|
|
278
|
-
</button>
|
|
279
|
-
)}
|
|
280
|
-
{/* Start button - shown for backlog/cancelled items */}
|
|
281
|
-
{canStart && onStatusChange && (
|
|
282
|
-
isHighlighted ? (
|
|
283
|
-
<motion.button
|
|
284
|
-
onClick={handleStart}
|
|
285
|
-
className="px-2 py-0.5 text-xs rounded border border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400"
|
|
286
|
-
animate={{
|
|
287
|
-
backgroundColor: [
|
|
288
|
-
'rgba(59, 130, 246, 0)',
|
|
289
|
-
'rgba(59, 130, 246, 0.15)',
|
|
290
|
-
'rgba(59, 130, 246, 0)',
|
|
291
|
-
],
|
|
292
|
-
}}
|
|
293
|
-
transition={{
|
|
294
|
-
duration: 2,
|
|
295
|
-
repeat: Infinity,
|
|
296
|
-
ease: 'easeInOut',
|
|
297
|
-
}}
|
|
298
|
-
aria-label="Start work"
|
|
299
|
-
data-testid={`start-button-${item.id}`}
|
|
300
|
-
>
|
|
301
|
-
start
|
|
302
|
-
</motion.button>
|
|
303
|
-
) : (
|
|
304
|
-
<button
|
|
305
|
-
onClick={handleStart}
|
|
306
|
-
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"
|
|
307
|
-
aria-label="Start work"
|
|
308
|
-
data-testid={`start-button-${item.id}`}
|
|
309
|
-
>
|
|
310
|
-
start
|
|
311
|
-
</button>
|
|
312
|
-
)
|
|
313
|
-
)}
|
|
314
|
-
{onStatusChange && (
|
|
315
|
-
<CardMenu
|
|
316
|
-
itemId={item.id}
|
|
317
|
-
itemTitle={item.title}
|
|
318
|
-
itemType={item.type}
|
|
319
|
-
itemDescription={item.description}
|
|
320
|
-
conversational={!!item.conversational}
|
|
321
|
-
currentStatus={item.status}
|
|
322
|
-
onStatusChange={handleStatusChange}
|
|
323
|
-
onTriggerClaude={onTriggerClaude}
|
|
324
|
-
hasActiveSession={hasActiveSession}
|
|
325
|
-
onOpenSession={onOpenSession}
|
|
326
|
-
usageAllowed={usageAllowed}
|
|
327
|
-
/>
|
|
328
|
-
)}
|
|
329
|
-
</div>
|
|
330
|
-
</div>
|
|
331
|
-
</div>
|
|
332
|
-
{/* Rejection reason input */}
|
|
333
|
-
{showRejectInput && (
|
|
334
|
-
<div className="px-3 pb-3 border-t border-zinc-200 dark:border-zinc-700" onClick={(e) => e.stopPropagation()}>
|
|
335
|
-
<div className="mt-2">
|
|
336
|
-
<input
|
|
337
|
-
type="text"
|
|
338
|
-
value={rejectReason}
|
|
339
|
-
onChange={(e) => setRejectReason(e.target.value)}
|
|
340
|
-
onKeyDown={(e) => {
|
|
341
|
-
if (e.key === 'Enter' && rejectReason.trim()) {
|
|
342
|
-
handleRejectConfirm(e as unknown as React.MouseEvent);
|
|
343
|
-
}
|
|
344
|
-
if (e.key === 'Escape') {
|
|
345
|
-
handleRejectCancel(e as unknown as React.MouseEvent);
|
|
346
|
-
}
|
|
347
|
-
}}
|
|
348
|
-
placeholder="Rejection reason..."
|
|
349
|
-
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"
|
|
350
|
-
autoFocus
|
|
351
|
-
data-testid={`reject-reason-input-${item.id}`}
|
|
352
|
-
/>
|
|
353
|
-
<div className="flex items-center gap-1 mt-1.5">
|
|
354
|
-
<button
|
|
355
|
-
onClick={handleRejectConfirm}
|
|
356
|
-
disabled={!rejectReason.trim()}
|
|
357
|
-
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"
|
|
358
|
-
data-testid={`reject-confirm-${item.id}`}
|
|
359
|
-
>
|
|
360
|
-
Reject
|
|
361
|
-
</button>
|
|
362
|
-
<button
|
|
363
|
-
onClick={handleRejectCancel}
|
|
364
|
-
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"
|
|
365
|
-
data-testid={`reject-cancel-${item.id}`}
|
|
366
|
-
>
|
|
367
|
-
Cancel
|
|
368
|
-
</button>
|
|
369
|
-
</div>
|
|
370
|
-
</div>
|
|
371
|
-
</div>
|
|
372
|
-
)}
|
|
373
|
-
{/* Rejected indicator */}
|
|
374
|
-
{item.rejection_reason && (
|
|
375
|
-
<div className="px-3 pb-2 border-t border-red-200 dark:border-red-800">
|
|
376
|
-
<div className="mt-1.5 flex items-start gap-1.5 text-xs text-red-600 dark:text-red-400">
|
|
377
|
-
<span className="flex-shrink-0">⚠️</span>
|
|
378
|
-
<span className="italic">{item.rejection_reason}</span>
|
|
379
|
-
</div>
|
|
380
|
-
</div>
|
|
381
|
-
)}
|
|
382
|
-
{/* Show expandable section for features with chores or bugs */}
|
|
383
|
-
{(hasChores || hasBugs) && (
|
|
384
|
-
<div className={`border-t ${isDone ? 'border-green-200 dark:border-green-800' : 'border-zinc-200 dark:border-zinc-700'}`}>
|
|
385
|
-
<button
|
|
386
|
-
onClick={() => setExpanded(!expanded)}
|
|
387
|
-
className={`w-full px-3 py-1.5 flex items-start gap-1.5 text-xs transition-colors ${
|
|
388
|
-
isDone
|
|
389
|
-
? 'text-green-700 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/30'
|
|
390
|
-
: 'text-zinc-600 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700/50'
|
|
391
|
-
}`}
|
|
392
|
-
>
|
|
393
|
-
<span className="mt-0.5">{expanded ? '▼' : '▶'}</span>
|
|
394
|
-
<div className="flex flex-col gap-0.5">
|
|
395
|
-
{hasChores && (
|
|
396
|
-
<div className="flex items-center gap-1.5">
|
|
397
|
-
<span>🔧</span>
|
|
398
|
-
<span>
|
|
399
|
-
{isDone
|
|
400
|
-
? `${allChores.length === 0 ? 'no' : allChores.length} chore${allChores.length !== 1 ? 's' : ''}`
|
|
401
|
-
: `${incompleteChores.length === 0 ? 'no' : incompleteChores.length}${item.mode ? ` ${item.mode} mode` : ''} chore${incompleteChores.length !== 1 ? 's' : ''} left`}
|
|
402
|
-
</span>
|
|
403
|
-
</div>
|
|
404
|
-
)}
|
|
405
|
-
{hasBugs && (
|
|
406
|
-
<div className="flex items-center gap-1.5">
|
|
407
|
-
<span>🐛</span>
|
|
408
|
-
<span>
|
|
409
|
-
{isDone
|
|
410
|
-
? `${allBugs.length === 0 ? 'no' : allBugs.length} bug${allBugs.length !== 1 ? 's' : ''}`
|
|
411
|
-
: `${incompleteBugs.length === 0 ? 'no' : incompleteBugs.length} bug${incompleteBugs.length !== 1 ? 's' : ''} left`}
|
|
412
|
-
</span>
|
|
413
|
-
</div>
|
|
414
|
-
)}
|
|
415
|
-
</div>
|
|
416
|
-
</button>
|
|
417
|
-
{expanded && (
|
|
418
|
-
<div className="px-3 pb-2 space-y-1">
|
|
419
|
-
{allChores.map((chore) => {
|
|
420
|
-
const isComplete = chore.status === 'done';
|
|
421
|
-
return (
|
|
422
|
-
<Link
|
|
423
|
-
key={chore.id}
|
|
424
|
-
href={`/work/${chore.id}`}
|
|
425
|
-
className={`block py-1 px-2 text-xs rounded transition-colors ${
|
|
426
|
-
isComplete
|
|
427
|
-
? 'bg-green-100 dark:bg-green-900/30 border border-green-200 dark:border-green-800/50'
|
|
428
|
-
: 'hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
|
429
|
-
}`}
|
|
430
|
-
>
|
|
431
|
-
<div className="flex items-center gap-2">
|
|
432
|
-
<span className={`font-mono ${isComplete ? 'text-zinc-500' : 'text-zinc-400'}`}>#{chore.id}</span>
|
|
433
|
-
{!isDone && chore.mode && modeLabels[chore.mode] && (
|
|
434
|
-
<span className={`px-1 py-0.5 rounded text-[10px] ${modeLabels[chore.mode].color}`}>
|
|
435
|
-
{getModeLabel(chore)}
|
|
436
|
-
</span>
|
|
437
|
-
)}
|
|
438
|
-
<span className={`truncate ${
|
|
439
|
-
isComplete
|
|
440
|
-
? 'text-zinc-500'
|
|
441
|
-
: 'text-zinc-700 dark:text-zinc-300'
|
|
442
|
-
}`}>
|
|
443
|
-
{chore.title || <span className="text-zinc-400 italic">(Untitled)</span>}
|
|
444
|
-
</span>
|
|
445
|
-
</div>
|
|
446
|
-
</Link>
|
|
447
|
-
);
|
|
448
|
-
})}
|
|
449
|
-
{allBugs.map((bug) => {
|
|
450
|
-
const isComplete = bug.status === 'done';
|
|
451
|
-
return (
|
|
452
|
-
<Link
|
|
453
|
-
key={bug.id}
|
|
454
|
-
href={`/work/${bug.id}`}
|
|
455
|
-
className={`block py-1 px-2 text-xs rounded transition-colors ${
|
|
456
|
-
isComplete
|
|
457
|
-
? 'bg-green-100 dark:bg-green-900/30 border border-green-200 dark:border-green-800/50'
|
|
458
|
-
: 'hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
|
459
|
-
}`}
|
|
460
|
-
>
|
|
461
|
-
<div className="flex items-center gap-2">
|
|
462
|
-
<span className={`font-mono ${isComplete ? 'text-zinc-500' : 'text-zinc-400'}`}>#{bug.id}</span>
|
|
463
|
-
<span>🐛</span>
|
|
464
|
-
<span className={`truncate ${
|
|
465
|
-
isComplete
|
|
466
|
-
? 'text-zinc-500'
|
|
467
|
-
: 'text-zinc-700 dark:text-zinc-300'
|
|
468
|
-
}`}>
|
|
469
|
-
{bug.title || <span className="text-zinc-400 italic">(Untitled)</span>}
|
|
470
|
-
</span>
|
|
471
|
-
</div>
|
|
472
|
-
</Link>
|
|
473
|
-
);
|
|
474
|
-
})}
|
|
475
|
-
</div>
|
|
476
|
-
)}
|
|
477
|
-
</div>
|
|
478
|
-
)}
|
|
479
|
-
</div>
|
|
480
|
-
</WaveCompletionAnimation>
|
|
481
|
-
);
|
|
482
|
-
|
|
483
|
-
if (isHighlighted) {
|
|
484
|
-
return (
|
|
485
|
-
<motion.div
|
|
486
|
-
animate={{
|
|
487
|
-
boxShadow: [
|
|
488
|
-
'0 0 0 0px rgba(129, 157, 159, 0)',
|
|
489
|
-
'0 0 0 3px rgba(129, 157, 159, 0.4)',
|
|
490
|
-
'0 0 0 0px rgba(129, 157, 159, 0)',
|
|
491
|
-
],
|
|
492
|
-
}}
|
|
493
|
-
transition={{
|
|
494
|
-
duration: 2,
|
|
495
|
-
repeat: Infinity,
|
|
496
|
-
ease: 'easeInOut',
|
|
497
|
-
}}
|
|
498
|
-
className="rounded-xl"
|
|
499
|
-
>
|
|
500
|
-
{cardContent}
|
|
501
|
-
</motion.div>
|
|
502
|
-
);
|
|
28
|
+
const visible: [string, KanbanGroup][] = [];
|
|
29
|
+
let count = 0;
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
if (count >= limit) break;
|
|
32
|
+
visible.push(entry);
|
|
33
|
+
count += entry[1].items.length;
|
|
503
34
|
}
|
|
504
|
-
|
|
505
|
-
return cardContent;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
// Safe bounds for display_order to prevent overflow
|
|
509
|
-
const MIN_DISPLAY_ORDER = 0;
|
|
510
|
-
const MAX_DISPLAY_ORDER = Number.MAX_SAFE_INTEGER - 1000;
|
|
511
|
-
const DISPLAY_ORDER_INCREMENT = 10;
|
|
512
|
-
|
|
513
|
-
interface EpicGroupProps {
|
|
514
|
-
epicId: number | null;
|
|
515
|
-
epicTitle: string | null;
|
|
516
|
-
items: WorkItem[];
|
|
517
|
-
isInFlight?: boolean;
|
|
518
|
-
isDraggable?: boolean;
|
|
519
|
-
onTitleSave?: (id: number, newTitle: string) => Promise<void>;
|
|
520
|
-
onStatusChange?: (id: number, newStatus: string) => Promise<void | { success: boolean; notFound?: boolean }>;
|
|
521
|
-
onReject?: (id: number, reason: string) => Promise<void>;
|
|
522
|
-
onEpicAssign?: (id: number, epicId: number | null) => Promise<void>;
|
|
523
|
-
onOrderChange?: (id: number, newOrder: number) => Promise<void>;
|
|
524
|
-
onTriggerClaude?: (id: number, title: string, type: string, conversational?: boolean, description?: string | null) => void;
|
|
525
|
-
activeSessions?: Map<string, Session>;
|
|
526
|
-
onOpenSession?: (id: string) => void;
|
|
527
|
-
onError?: (message: string) => void;
|
|
528
|
-
usageAllowed?: boolean;
|
|
529
|
-
// Animation state lifted to board level
|
|
530
|
-
animatingItemId?: number | null;
|
|
531
|
-
onAnimationComplete?: () => void;
|
|
532
|
-
isBlank?: boolean;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable = true, onTitleSave, onStatusChange, onReject, onEpicAssign, onOrderChange, onTriggerClaude, activeSessions, onOpenSession, onError, usageAllowed = true, animatingItemId, onAnimationComplete, isBlank }: EpicGroupProps) {
|
|
536
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
537
|
-
const { isDragging, draggedItem, activeEpicZone, activeDropZone, registerEpicDropZone, unregisterEpicDropZone, getCardPositions } = useDragContext();
|
|
538
|
-
|
|
539
|
-
// Local pointer tracking - only this component needs pointer Y for insertion preview.
|
|
540
|
-
// Using local state avoids re-rendering every context consumer at 60fps.
|
|
541
|
-
const [pointerY, setPointerY] = useState(0);
|
|
542
|
-
useEffect(() => {
|
|
543
|
-
if (!isDragging) return;
|
|
544
|
-
const onPointerMove = (e: PointerEvent) => { setPointerY(e.clientY); };
|
|
545
|
-
window.addEventListener('pointermove', onPointerMove);
|
|
546
|
-
return () => window.removeEventListener('pointermove', onPointerMove);
|
|
547
|
-
}, [isDragging]);
|
|
548
|
-
|
|
549
|
-
// Use @dnd-kit's useDroppable for epic zone collision detection
|
|
550
|
-
const zoneId = epicId !== null ? `epic-${epicId}` : undefined;
|
|
551
|
-
const { setNodeRef } = useDroppable({
|
|
552
|
-
id: zoneId || 'ungrouped',
|
|
553
|
-
disabled: epicId === null, // Don't use droppable for ungrouped section
|
|
554
|
-
data: { epicId },
|
|
555
|
-
});
|
|
556
|
-
|
|
557
|
-
// Combine refs
|
|
558
|
-
const setRefs = useCallback((node: HTMLDivElement | null) => {
|
|
559
|
-
if (epicId !== null) {
|
|
560
|
-
setNodeRef(node);
|
|
561
|
-
}
|
|
562
|
-
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
|
563
|
-
}, [epicId, setNodeRef]);
|
|
564
|
-
|
|
565
|
-
// Use ref for items to avoid re-registering drop zone when items change
|
|
566
|
-
const itemsRef = useRef(items);
|
|
567
|
-
itemsRef.current = items;
|
|
568
|
-
|
|
569
|
-
// Use ref for callbacks to keep drop zone registration stable
|
|
570
|
-
const onOrderChangeRef = useRef(onOrderChange);
|
|
571
|
-
onOrderChangeRef.current = onOrderChange;
|
|
572
|
-
|
|
573
|
-
// Use ref for error handler to keep reorder handler stable
|
|
574
|
-
const onErrorRef = useRef(onError);
|
|
575
|
-
onErrorRef.current = onError;
|
|
576
|
-
|
|
577
|
-
// Stable reorder handler that reads from refs
|
|
578
|
-
const handleEpicReorder = useCallback(async (itemId: number, pointerY: number) => {
|
|
579
|
-
if (!onOrderChangeRef.current) {
|
|
580
|
-
return;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
const currentItems = itemsRef.current.filter(item => item.id !== itemId);
|
|
584
|
-
if (currentItems.length === 0) {
|
|
585
|
-
return;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
// Read fresh positions from DOM (not stale cache)
|
|
589
|
-
const allPositions = getCardPositions();
|
|
590
|
-
const itemIds = new Set(currentItems.map(item => item.id));
|
|
591
|
-
const cardPositions = allPositions
|
|
592
|
-
.filter(pos => itemIds.has(pos.id))
|
|
593
|
-
.map(pos => ({
|
|
594
|
-
id: pos.id,
|
|
595
|
-
midY: (pos.rect.top + pos.rect.bottom) / 2,
|
|
596
|
-
}))
|
|
597
|
-
.sort((a, b) => a.midY - b.midY);
|
|
598
|
-
|
|
599
|
-
if (cardPositions.length === 0) {
|
|
600
|
-
return;
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
// Find insertion index based on pointer Y
|
|
604
|
-
let insertIndex = cardPositions.length;
|
|
605
|
-
for (let i = 0; i < cardPositions.length; i++) {
|
|
606
|
-
if (pointerY < cardPositions[i].midY) {
|
|
607
|
-
insertIndex = i;
|
|
608
|
-
break;
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
// Map visual positions to items for display_order midpoint calculation
|
|
613
|
-
const itemMap = new Map(currentItems.map(item => [item.id, item]));
|
|
614
|
-
const visualOrder = cardPositions.map(pos => itemMap.get(pos.id)!).filter(Boolean);
|
|
615
|
-
|
|
616
|
-
// Calculate proper midpoint display_order between surrounding items
|
|
617
|
-
let newOrder: number;
|
|
618
|
-
if (visualOrder.length === 0) {
|
|
619
|
-
newOrder = DISPLAY_ORDER_INCREMENT;
|
|
620
|
-
} else if (insertIndex === 0) {
|
|
621
|
-
const firstOrder = visualOrder[0].display_order ?? visualOrder[0].id;
|
|
622
|
-
newOrder = firstOrder - DISPLAY_ORDER_INCREMENT;
|
|
623
|
-
} else if (insertIndex >= visualOrder.length) {
|
|
624
|
-
const lastOrder = visualOrder[visualOrder.length - 1].display_order ?? visualOrder[visualOrder.length - 1].id;
|
|
625
|
-
newOrder = lastOrder + DISPLAY_ORDER_INCREMENT;
|
|
626
|
-
} else {
|
|
627
|
-
const before = visualOrder[insertIndex - 1].display_order ?? visualOrder[insertIndex - 1].id;
|
|
628
|
-
const after = visualOrder[insertIndex].display_order ?? visualOrder[insertIndex].id;
|
|
629
|
-
newOrder = Math.floor((before + after) / 2);
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
newOrder = Math.max(MIN_DISPLAY_ORDER, Math.min(MAX_DISPLAY_ORDER, newOrder));
|
|
633
|
-
|
|
634
|
-
try {
|
|
635
|
-
await onOrderChangeRef.current(itemId, newOrder);
|
|
636
|
-
} catch (error) {
|
|
637
|
-
const errorMessage = error instanceof Error ? error.message : 'Failed to reorder item. Please try again.';
|
|
638
|
-
onErrorRef.current?.(errorMessage);
|
|
639
|
-
}
|
|
640
|
-
}, [getCardPositions]);
|
|
641
|
-
|
|
642
|
-
// Register as epic drop zone - stable registration that doesn't change with items
|
|
643
|
-
useEffect(() => {
|
|
644
|
-
if (!containerRef.current || !onEpicAssign || epicId === null) return;
|
|
645
|
-
|
|
646
|
-
const zoneId = `epic-${epicId}`;
|
|
647
|
-
registerEpicDropZone(zoneId, {
|
|
648
|
-
epicId,
|
|
649
|
-
element: containerRef.current,
|
|
650
|
-
onEpicAssign,
|
|
651
|
-
onReorder: handleEpicReorder,
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
return () => {
|
|
655
|
-
unregisterEpicDropZone(zoneId);
|
|
656
|
-
};
|
|
657
|
-
}, [epicId, onEpicAssign, handleEpicReorder, registerEpicDropZone, unregisterEpicDropZone]);
|
|
658
|
-
|
|
659
|
-
// Check if this epic zone is the active drop target
|
|
660
|
-
const isActiveTarget = activeEpicZone === `epic-${epicId}`;
|
|
661
|
-
|
|
662
|
-
// Check if the dragged item is from a different epic or same epic
|
|
663
|
-
const draggedItemEpicId = draggedItem ? (draggedItem.parent_id || draggedItem.epic_id) : null;
|
|
664
|
-
const isDifferentEpic = isDragging && draggedItem && draggedItemEpicId !== epicId;
|
|
665
|
-
const isSameEpic = isDragging && draggedItem && draggedItemEpicId === epicId;
|
|
666
|
-
|
|
667
|
-
// Show highlight when dragging an item from different epic over this group (indigo)
|
|
668
|
-
const showHighlight = isActiveTarget && isDifferentEpic;
|
|
669
|
-
// Show reorder highlight when dragging within same epic (purple)
|
|
670
|
-
const showReorderHighlight = isActiveTarget && isSameEpic;
|
|
671
|
-
|
|
672
|
-
// For ungrouped section (epicId === null)
|
|
673
|
-
const isUngroupedSection = epicId === null;
|
|
674
|
-
// Check if cursor is over this ungrouped section (not over any epic zone, but over backlog drop zone)
|
|
675
|
-
const isOverUngroupedSection = isUngroupedSection && !activeEpicZone && activeDropZone;
|
|
676
|
-
|
|
677
|
-
// Render the ungrouped zone when dragging from an epic (provides drop target), but only highlight when cursor is over it
|
|
678
|
-
const shouldRenderUngroupedZone = isUngroupedSection && isDragging && draggedItemEpicId !== null;
|
|
679
|
-
const showRemoveFromEpicZone = isOverUngroupedSection && isDragging && draggedItemEpicId !== null;
|
|
680
|
-
|
|
681
|
-
// Show reorder for ungrouped section when dragging an ungrouped card and cursor is over it
|
|
682
|
-
const showUngroupedReorder = isOverUngroupedSection && isDragging && draggedItemEpicId === null;
|
|
683
|
-
|
|
684
|
-
// Calculate insertion preview for this group - only for the active zone
|
|
685
|
-
const showPreview = (showReorderHighlight || showRemoveFromEpicZone || showHighlight || showUngroupedReorder) && draggedItem;
|
|
686
|
-
let insertAfterItemId: number | null | undefined = undefined; // undefined = no preview, null = at beginning
|
|
687
|
-
|
|
688
|
-
if (showPreview && draggedItem) {
|
|
689
|
-
const allPositions = getCardPositions();
|
|
690
|
-
const itemIds = new Set(items.map(item => item.id));
|
|
691
|
-
const groupPositions = allPositions
|
|
692
|
-
.filter(pos => itemIds.has(pos.id) && pos.id !== draggedItem.id)
|
|
693
|
-
.map(pos => ({
|
|
694
|
-
id: pos.id,
|
|
695
|
-
midY: (pos.rect.top + pos.rect.bottom) / 2,
|
|
696
|
-
}))
|
|
697
|
-
.sort((a, b) => a.midY - b.midY);
|
|
698
|
-
|
|
699
|
-
// Find which card the pointer is after
|
|
700
|
-
insertAfterItemId = null; // Default to beginning
|
|
701
|
-
for (const pos of groupPositions) {
|
|
702
|
-
if (pointerY > pos.midY) {
|
|
703
|
-
insertAfterItemId = pos.id;
|
|
704
|
-
} else {
|
|
705
|
-
break;
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
if (items.length === 0 && !showHighlight && !showReorderHighlight && !shouldRenderUngroupedZone) return null;
|
|
711
|
-
|
|
712
|
-
// Standalone done items (single item, no epic) use tighter spacing
|
|
713
|
-
const isStandaloneItem = !epicTitle && items.length === 1;
|
|
714
|
-
|
|
715
|
-
return (
|
|
716
|
-
<div
|
|
717
|
-
ref={setRefs}
|
|
718
|
-
className={`${isStandaloneItem ? 'mb-2' : 'mb-4 p-2 -mx-2'} rounded-lg transition-all ${
|
|
719
|
-
showHighlight
|
|
720
|
-
? 'ring-2 ring-indigo-400 bg-indigo-100/50 dark:bg-indigo-900/30'
|
|
721
|
-
: showReorderHighlight
|
|
722
|
-
? 'ring-2 ring-purple-400 bg-purple-100/50 dark:bg-purple-900/30'
|
|
723
|
-
: showRemoveFromEpicZone
|
|
724
|
-
? 'ring-2 ring-orange-400 bg-orange-100/50 dark:bg-orange-900/30'
|
|
725
|
-
: ''
|
|
726
|
-
}`}
|
|
727
|
-
data-epic-id={epicId}
|
|
728
|
-
>
|
|
729
|
-
{epicTitle && (
|
|
730
|
-
<div className="flex items-center gap-2 mb-2">
|
|
731
|
-
<Link
|
|
732
|
-
href={`/work/${epicId}`}
|
|
733
|
-
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"
|
|
734
|
-
>
|
|
735
|
-
<span>🎯</span>
|
|
736
|
-
<span>{epicTitle}</span>
|
|
737
|
-
</Link>
|
|
738
|
-
{isInFlight && (
|
|
739
|
-
<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">
|
|
740
|
-
in flight
|
|
741
|
-
</span>
|
|
742
|
-
)}
|
|
743
|
-
{showHighlight && (
|
|
744
|
-
<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">
|
|
745
|
-
drop to assign
|
|
746
|
-
</span>
|
|
747
|
-
)}
|
|
748
|
-
{showReorderHighlight && (
|
|
749
|
-
<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">
|
|
750
|
-
reorder
|
|
751
|
-
</span>
|
|
752
|
-
)}
|
|
753
|
-
</div>
|
|
754
|
-
)}
|
|
755
|
-
{/* Ungrouped section header - shown when dragging from epic */}
|
|
756
|
-
{isUngroupedSection && showRemoveFromEpicZone && items.length === 0 && (
|
|
757
|
-
<div className="flex items-center gap-2 py-3">
|
|
758
|
-
<span className="text-xs font-medium text-orange-600 dark:text-orange-400">
|
|
759
|
-
Drop here to remove from epic
|
|
760
|
-
</span>
|
|
761
|
-
</div>
|
|
762
|
-
)}
|
|
763
|
-
{isUngroupedSection && items.length > 0 && isDraggable && showRemoveFromEpicZone && (
|
|
764
|
-
<div className="flex items-center gap-2 mb-2">
|
|
765
|
-
<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">
|
|
766
|
-
drop to remove from epic
|
|
767
|
-
</span>
|
|
768
|
-
</div>
|
|
769
|
-
)}
|
|
770
|
-
<div className="space-y-2">
|
|
771
|
-
{/* Placeholder at the beginning (insertAfterItemId === null) */}
|
|
772
|
-
<AnimatePresence>
|
|
773
|
-
{insertAfterItemId === null && (
|
|
774
|
-
<PlaceholderCard key="placeholder-start" />
|
|
775
|
-
)}
|
|
776
|
-
</AnimatePresence>
|
|
777
|
-
{items.map((item) => (
|
|
778
|
-
<div key={item.id}>
|
|
779
|
-
<DraggableCard item={item} disabled={!isDraggable}>
|
|
780
|
-
<KanbanCard
|
|
781
|
-
item={item}
|
|
782
|
-
onTitleSave={onTitleSave}
|
|
783
|
-
onStatusChange={onStatusChange}
|
|
784
|
-
onReject={onReject}
|
|
785
|
-
onTriggerClaude={onTriggerClaude}
|
|
786
|
-
hasActiveSession={activeSessions?.has(String(item.id))}
|
|
787
|
-
onOpenSession={onOpenSession}
|
|
788
|
-
usageAllowed={usageAllowed}
|
|
789
|
-
isCompletingAnimation={animatingItemId === item.id}
|
|
790
|
-
onAnimationComplete={onAnimationComplete}
|
|
791
|
-
isHighlighted={isBlank && item.status === 'backlog' && item.title === 'Align on the user journey'}
|
|
792
|
-
/>
|
|
793
|
-
</DraggableCard>
|
|
794
|
-
{/* Placeholder after this card */}
|
|
795
|
-
<AnimatePresence>
|
|
796
|
-
{insertAfterItemId === item.id && (
|
|
797
|
-
<PlaceholderCard key={`placeholder-${item.id}`} />
|
|
798
|
-
)}
|
|
799
|
-
</AnimatePresence>
|
|
800
|
-
</div>
|
|
801
|
-
))}
|
|
802
|
-
</div>
|
|
803
|
-
</div>
|
|
804
|
-
);
|
|
35
|
+
return { visible, totalCount, hasMore: count < totalCount };
|
|
805
36
|
}
|
|
806
37
|
|
|
807
38
|
interface KanbanColumnProps {
|
|
@@ -810,51 +41,44 @@ interface KanbanColumnProps {
|
|
|
810
41
|
count: number;
|
|
811
42
|
onAdd?: () => void;
|
|
812
43
|
addDisabled?: boolean;
|
|
44
|
+
scrollRef?: React.RefObject<HTMLDivElement | null>;
|
|
813
45
|
}
|
|
814
46
|
|
|
815
|
-
function KanbanColumn({ title, children, count, onAdd, addDisabled }: KanbanColumnProps) {
|
|
47
|
+
function KanbanColumn({ title, children, count, onAdd, addDisabled, scrollRef }: KanbanColumnProps) {
|
|
816
48
|
const testId = title.toLowerCase().replace(/\s+/g, '-') + '-column';
|
|
817
49
|
return (
|
|
818
|
-
<div className="flex-1
|
|
50
|
+
<div className="flex-1 max-w-[600px] flex flex-col min-h-0" data-testid={testId}>
|
|
819
51
|
<div
|
|
820
|
-
className="bg-zinc-100 dark:bg-zinc-900 rounded-xl p-
|
|
821
|
-
style={{
|
|
822
|
-
boxShadow: `
|
|
823
|
-
0 1px 2px rgba(0, 0, 0, 0.04),
|
|
824
|
-
0 2px 4px rgba(0, 0, 0, 0.04),
|
|
825
|
-
0 4px 8px rgba(0, 0, 0, 0.04),
|
|
826
|
-
0 8px 16px rgba(0, 0, 0, 0.02),
|
|
827
|
-
inset 0 1px 0 rgba(255, 255, 255, 0.5)
|
|
828
|
-
`,
|
|
829
|
-
}}
|
|
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 }}
|
|
830
54
|
>
|
|
831
|
-
<div className="flex items-center justify-between mb-
|
|
55
|
+
<div className="flex items-center justify-between mb-4 flex-shrink-0">
|
|
832
56
|
<h2 className="font-semibold text-zinc-900 dark:text-zinc-100">{title}</h2>
|
|
833
57
|
<div className="flex items-center gap-2">
|
|
834
58
|
{onAdd && (
|
|
835
59
|
<button
|
|
836
60
|
onClick={onAdd}
|
|
837
61
|
disabled={addDisabled}
|
|
838
|
-
className={`p-1 rounded transition-colors ${
|
|
62
|
+
className={`p-1 rounded transition-colors duration-200 ease-out ${
|
|
839
63
|
addDisabled
|
|
840
64
|
? 'text-zinc-300 dark:text-zinc-700 cursor-not-allowed'
|
|
841
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'
|
|
842
66
|
}`}
|
|
843
67
|
aria-label={`Add to ${title.toLowerCase()}`}
|
|
844
68
|
data-testid={`${testId}-add-button`}
|
|
845
|
-
title={addDisabled ? 'Weekly usage limit reached' :
|
|
69
|
+
title={addDisabled ? 'Weekly usage limit reached' : `Add to ${title.toLowerCase()}`}
|
|
846
70
|
>
|
|
847
71
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
848
72
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
849
73
|
</svg>
|
|
850
74
|
</button>
|
|
851
75
|
)}
|
|
852
|
-
<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">
|
|
853
77
|
{count}
|
|
854
78
|
</span>
|
|
855
79
|
</div>
|
|
856
80
|
</div>
|
|
857
|
-
<div className="overflow-y-auto flex-1 min-h-0 px-
|
|
81
|
+
<div ref={scrollRef} className="overflow-y-auto overflow-x-hidden flex-1 min-h-0 px-3 -mx-3" style={{ contain: 'paint' }}>
|
|
858
82
|
{children}
|
|
859
83
|
</div>
|
|
860
84
|
</div>
|
|
@@ -921,19 +145,20 @@ function BacklogDropZoneWrapper({ backlog, onStatusChange, onOrderChange, childr
|
|
|
921
145
|
const itemMap = new Map(backlogItems.map(item => [item.id, item]));
|
|
922
146
|
const visualOrder = cardPositions.map(pos => itemMap.get(pos.id)!).filter(Boolean);
|
|
923
147
|
|
|
924
|
-
// Calculate proper midpoint display_order between surrounding items
|
|
148
|
+
// Calculate proper midpoint display_order between surrounding items.
|
|
149
|
+
// Fallback uses id * INCREMENT to match the sort comparator and give proper gaps.
|
|
925
150
|
let newOrder: number;
|
|
926
151
|
if (visualOrder.length === 0) {
|
|
927
152
|
newOrder = DISPLAY_ORDER_INCREMENT;
|
|
928
153
|
} else if (insertIndex === 0) {
|
|
929
|
-
const firstOrder = visualOrder[0].display_order ?? visualOrder[0].id;
|
|
154
|
+
const firstOrder = visualOrder[0].display_order ?? visualOrder[0].id * DISPLAY_ORDER_INCREMENT;
|
|
930
155
|
newOrder = firstOrder - DISPLAY_ORDER_INCREMENT;
|
|
931
156
|
} else if (insertIndex >= visualOrder.length) {
|
|
932
|
-
const lastOrder = visualOrder[visualOrder.length - 1].display_order ?? visualOrder[visualOrder.length - 1].id;
|
|
157
|
+
const lastOrder = visualOrder[visualOrder.length - 1].display_order ?? visualOrder[visualOrder.length - 1].id * DISPLAY_ORDER_INCREMENT;
|
|
933
158
|
newOrder = lastOrder + DISPLAY_ORDER_INCREMENT;
|
|
934
159
|
} else {
|
|
935
|
-
const before = visualOrder[insertIndex - 1].display_order ?? visualOrder[insertIndex - 1].id;
|
|
936
|
-
const after = visualOrder[insertIndex].display_order ?? visualOrder[insertIndex].id;
|
|
160
|
+
const before = visualOrder[insertIndex - 1].display_order ?? visualOrder[insertIndex - 1].id * DISPLAY_ORDER_INCREMENT;
|
|
161
|
+
const after = visualOrder[insertIndex].display_order ?? visualOrder[insertIndex].id * DISPLAY_ORDER_INCREMENT;
|
|
937
162
|
newOrder = Math.floor((before + after) / 2);
|
|
938
163
|
}
|
|
939
164
|
|
|
@@ -950,9 +175,9 @@ function BacklogDropZoneWrapper({ backlog, onStatusChange, onOrderChange, childr
|
|
|
950
175
|
}}
|
|
951
176
|
onReorder={handleBacklogReorder}
|
|
952
177
|
allowReorder={true}
|
|
953
|
-
className="rounded-lg p-
|
|
954
|
-
highlightClassName="ring-2 ring-
|
|
955
|
-
reorderHighlightClassName="ring-2 ring-
|
|
178
|
+
className="rounded-lg p-3 -m-3 min-h-[100px]"
|
|
179
|
+
highlightClassName="ring-2 ring-[#819D9F] bg-[#E8EEEF]/50 dark:bg-[#819D9F]/20"
|
|
180
|
+
reorderHighlightClassName="ring-2 ring-[#E3D985] bg-[#F9F7E8]/50 dark:bg-[#E3D985]/20"
|
|
956
181
|
data-testid="backlog-drop-zone"
|
|
957
182
|
>
|
|
958
183
|
{children}
|
|
@@ -960,6 +185,153 @@ function BacklogDropZoneWrapper({ backlog, onStatusChange, onOrderChange, childr
|
|
|
960
185
|
);
|
|
961
186
|
}
|
|
962
187
|
|
|
188
|
+
// Virtualized list of EpicGroups within a column's scroll container.
|
|
189
|
+
// Each EpicGroup is a virtual row — off-screen groups are not rendered at all,
|
|
190
|
+
// saving DOM nodes and mount/unmount overhead.
|
|
191
|
+
type VirtualRow =
|
|
192
|
+
| { type: 'group'; key: string; group: KanbanGroup }
|
|
193
|
+
| { type: 'showMore' }
|
|
194
|
+
| { type: 'empty' };
|
|
195
|
+
|
|
196
|
+
interface VirtualizedEpicListProps {
|
|
197
|
+
entries: [string, KanbanGroup][];
|
|
198
|
+
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
199
|
+
scrollMargin?: number;
|
|
200
|
+
hasMore: boolean;
|
|
201
|
+
onShowMore: () => void;
|
|
202
|
+
emptyMessage?: string;
|
|
203
|
+
// EpicGroup props pass-through
|
|
204
|
+
inFlightByEpic?: Map<number, InFlightItem[]>;
|
|
205
|
+
isDraggable?: boolean;
|
|
206
|
+
onTitleSave?: (id: number, newTitle: string) => Promise<void>;
|
|
207
|
+
onStatusChange?: (id: number, newStatus: string) => Promise<void | { success: boolean; notFound?: boolean }>;
|
|
208
|
+
onReject?: (id: number, reason: string) => Promise<void>;
|
|
209
|
+
onRestart?: (id: number) => void;
|
|
210
|
+
onEpicAssign?: (id: number, epicId: number | null) => Promise<void>;
|
|
211
|
+
onOrderChange?: (id: number, newOrder: number) => Promise<void>;
|
|
212
|
+
onTriggerClaude?: (id: number, title: string, type: string, conversational?: boolean, description?: string | null) => void;
|
|
213
|
+
activeSessionIds?: Set<string>;
|
|
214
|
+
onOpenSession?: (id: string) => void;
|
|
215
|
+
onCloseSession?: (id: string) => void;
|
|
216
|
+
onError?: (message: string) => void;
|
|
217
|
+
usageAllowed?: boolean;
|
|
218
|
+
animatingItemId?: number | null;
|
|
219
|
+
onAnimationComplete?: () => void;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const VirtualizedEpicList = memo(function VirtualizedEpicList({
|
|
223
|
+
entries,
|
|
224
|
+
scrollRef,
|
|
225
|
+
scrollMargin = 0,
|
|
226
|
+
hasMore,
|
|
227
|
+
onShowMore,
|
|
228
|
+
emptyMessage,
|
|
229
|
+
inFlightByEpic,
|
|
230
|
+
isDraggable,
|
|
231
|
+
onTitleSave,
|
|
232
|
+
onStatusChange,
|
|
233
|
+
onReject,
|
|
234
|
+
onRestart,
|
|
235
|
+
onEpicAssign,
|
|
236
|
+
onOrderChange,
|
|
237
|
+
onTriggerClaude,
|
|
238
|
+
activeSessionIds,
|
|
239
|
+
onOpenSession,
|
|
240
|
+
onCloseSession,
|
|
241
|
+
onError,
|
|
242
|
+
usageAllowed,
|
|
243
|
+
animatingItemId,
|
|
244
|
+
onAnimationComplete,
|
|
245
|
+
}: VirtualizedEpicListProps) {
|
|
246
|
+
const rows = useMemo<VirtualRow[]>(() => {
|
|
247
|
+
const r: VirtualRow[] = [];
|
|
248
|
+
for (const [key, group] of entries) {
|
|
249
|
+
r.push({ type: 'group', key, group });
|
|
250
|
+
}
|
|
251
|
+
if (hasMore) r.push({ type: 'showMore' });
|
|
252
|
+
if (entries.length === 0 && emptyMessage) r.push({ type: 'empty' });
|
|
253
|
+
return r;
|
|
254
|
+
}, [entries, hasMore, emptyMessage]);
|
|
255
|
+
|
|
256
|
+
const virtualizer = useVirtualizer({
|
|
257
|
+
count: rows.length,
|
|
258
|
+
getScrollElement: () => scrollRef.current,
|
|
259
|
+
estimateSize: (index) => {
|
|
260
|
+
const row = rows[index];
|
|
261
|
+
if (!row || row.type === 'showMore' || row.type === 'empty') return 44;
|
|
262
|
+
// Estimate: header (~36px) + cards * ~95px per card + group padding
|
|
263
|
+
const isStandalone = !row.group.epicTitle && row.group.items.length === 1;
|
|
264
|
+
return isStandalone
|
|
265
|
+
? 82 + 8 // card height + spacing
|
|
266
|
+
: 36 + row.group.items.length * 95 + 24; // header + cards + spacing
|
|
267
|
+
},
|
|
268
|
+
scrollMargin,
|
|
269
|
+
overscan: 3,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
if (rows.length === 0) return null;
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
|
|
276
|
+
{virtualizer.getVirtualItems().map((virtualItem) => {
|
|
277
|
+
const row = rows[virtualItem.index];
|
|
278
|
+
// Bottom padding replaces EpicGroup's mb-* (margin is invisible with absolute positioning)
|
|
279
|
+
const isStandalone = row.type === 'group' && !row.group.epicTitle && row.group.items.length === 1;
|
|
280
|
+
const rowPaddingBottom = row.type === 'group' ? (isStandalone ? 8 : 24) : 0;
|
|
281
|
+
return (
|
|
282
|
+
<div
|
|
283
|
+
key={virtualItem.key}
|
|
284
|
+
data-index={virtualItem.index}
|
|
285
|
+
ref={virtualizer.measureElement}
|
|
286
|
+
style={{
|
|
287
|
+
position: 'absolute',
|
|
288
|
+
top: 0,
|
|
289
|
+
left: 0,
|
|
290
|
+
width: '100%',
|
|
291
|
+
transform: `translateY(${virtualItem.start - scrollMargin}px)`,
|
|
292
|
+
paddingBottom: rowPaddingBottom,
|
|
293
|
+
}}
|
|
294
|
+
>
|
|
295
|
+
{row.type === 'group' ? (
|
|
296
|
+
<EpicGroup
|
|
297
|
+
epicId={row.group.epicId}
|
|
298
|
+
epicTitle={row.group.epicTitle}
|
|
299
|
+
items={row.group.items}
|
|
300
|
+
isInFlight={row.group.epicId ? inFlightByEpic?.has(row.group.epicId) : false}
|
|
301
|
+
inFlightItems={row.group.epicId ? inFlightByEpic?.get(row.group.epicId) : undefined}
|
|
302
|
+
isDraggable={isDraggable}
|
|
303
|
+
onTitleSave={onTitleSave}
|
|
304
|
+
onStatusChange={onStatusChange}
|
|
305
|
+
onReject={onReject}
|
|
306
|
+
onRestart={onRestart}
|
|
307
|
+
onEpicAssign={onEpicAssign}
|
|
308
|
+
onOrderChange={onOrderChange}
|
|
309
|
+
onTriggerClaude={onTriggerClaude}
|
|
310
|
+
activeSessionIds={activeSessionIds}
|
|
311
|
+
onOpenSession={onOpenSession}
|
|
312
|
+
onCloseSession={onCloseSession}
|
|
313
|
+
onError={onError}
|
|
314
|
+
usageAllowed={usageAllowed}
|
|
315
|
+
animatingItemId={animatingItemId}
|
|
316
|
+
onAnimationComplete={onAnimationComplete}
|
|
317
|
+
/>
|
|
318
|
+
) : row.type === 'showMore' ? (
|
|
319
|
+
<button
|
|
320
|
+
onClick={onShowMore}
|
|
321
|
+
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"
|
|
322
|
+
>
|
|
323
|
+
Show more
|
|
324
|
+
</button>
|
|
325
|
+
) : (
|
|
326
|
+
<p className="text-base text-zinc-500 text-center py-4">{emptyMessage}</p>
|
|
327
|
+
)}
|
|
328
|
+
</div>
|
|
329
|
+
);
|
|
330
|
+
})}
|
|
331
|
+
</div>
|
|
332
|
+
);
|
|
333
|
+
});
|
|
334
|
+
|
|
963
335
|
interface KanbanBoardProps {
|
|
964
336
|
inFlight: InFlightItem[];
|
|
965
337
|
backlog: Map<string, KanbanGroup>;
|
|
@@ -967,12 +339,14 @@ interface KanbanBoardProps {
|
|
|
967
339
|
onTitleSave?: (id: number, newTitle: string) => Promise<void>;
|
|
968
340
|
onStatusChange?: (id: number, newStatus: string) => Promise<void | { success: boolean; notFound?: boolean }>;
|
|
969
341
|
onReject?: (id: number, reason: string) => Promise<void>;
|
|
342
|
+
onRestart?: (id: number) => void;
|
|
970
343
|
onOrderChange?: (id: number, newOrder: number) => Promise<void>;
|
|
971
344
|
onEpicAssign?: (id: number, epicId: number | null) => Promise<void>;
|
|
972
345
|
onTriggerClaude?: (id: number, title: string, type: string, conversational?: boolean, description?: string | null) => void;
|
|
973
346
|
// Multi-session support
|
|
974
347
|
onOpenSession?: (id: string) => void;
|
|
975
|
-
|
|
348
|
+
onCloseSession?: (id: string) => void;
|
|
349
|
+
activeSessionIds?: Set<string>;
|
|
976
350
|
// Undo/redo support
|
|
977
351
|
onUndo?: () => Promise<UndoAction | null>;
|
|
978
352
|
onRedo?: () => Promise<UndoAction | null>;
|
|
@@ -980,6 +354,8 @@ interface KanbanBoardProps {
|
|
|
980
354
|
canRedo?: boolean;
|
|
981
355
|
// Error handler for drag-drop operations
|
|
982
356
|
onError?: (message: string) => void;
|
|
357
|
+
// Pre-built status map from data-bridge (avoids O(N) rebuild per render)
|
|
358
|
+
itemStatusMap?: Map<number, string>;
|
|
983
359
|
// Add to backlog
|
|
984
360
|
onAddToBacklog?: () => void;
|
|
985
361
|
// Usage limits
|
|
@@ -987,13 +363,46 @@ interface KanbanBoardProps {
|
|
|
987
363
|
// External animation trigger (e.g., from CLI/DB completions detected via WebSocket)
|
|
988
364
|
externalAnimatingItemId?: number | null;
|
|
989
365
|
onExternalAnimationComplete?: () => void;
|
|
990
|
-
isBlank?: boolean;
|
|
991
366
|
}
|
|
992
367
|
|
|
993
|
-
export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChange, onReject, onOrderChange, onEpicAssign, onTriggerClaude, onOpenSession,
|
|
368
|
+
export const KanbanBoard = memo(function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChange, onReject, onRestart, onOrderChange, onEpicAssign, onTriggerClaude, onOpenSession, onCloseSession, activeSessionIds, onUndo, onRedo, canUndo, canRedo, onError, itemStatusMap: externalStatusMap, onAddToBacklog, usageAllowed = true, externalAnimatingItemId, onExternalAnimationComplete }: KanbanBoardProps) {
|
|
994
369
|
const backlogCount = inFlight.length + Array.from(backlog.values()).reduce((sum, g) => sum + g.items.length, 0);
|
|
995
370
|
const doneCount = Array.from(done.values()).reduce((sum, g) => sum + g.items.length, 0);
|
|
996
371
|
|
|
372
|
+
// Scroll container refs for virtualized columns
|
|
373
|
+
const backlogScrollRef = useRef<HTMLDivElement>(null);
|
|
374
|
+
const doneScrollRef = useRef<HTMLDivElement>(null);
|
|
375
|
+
|
|
376
|
+
// Measure non-virtualized content above the backlog virtualizer (In Flight + divider)
|
|
377
|
+
// so the virtualizer knows the correct scroll offset
|
|
378
|
+
const preBacklogRef = useRef<HTMLDivElement>(null);
|
|
379
|
+
const [backlogScrollMargin, setBacklogScrollMargin] = useState(0);
|
|
380
|
+
useEffect(() => {
|
|
381
|
+
const el = preBacklogRef.current;
|
|
382
|
+
if (!el) return;
|
|
383
|
+
const ro = new ResizeObserver((entries) => {
|
|
384
|
+
setBacklogScrollMargin(entries[0]?.borderBoxSize?.[0]?.blockSize ?? el.offsetHeight);
|
|
385
|
+
});
|
|
386
|
+
ro.observe(el);
|
|
387
|
+
return () => ro.disconnect();
|
|
388
|
+
}, []);
|
|
389
|
+
|
|
390
|
+
// Lazy loading state for backlog and done columns
|
|
391
|
+
const [showAllBacklog, setShowAllBacklog] = useState(false);
|
|
392
|
+
const [showAllDone, setShowAllDone] = useState(false);
|
|
393
|
+
|
|
394
|
+
const backlogEntries = useMemo(() => Array.from(backlog.entries()), [backlog]);
|
|
395
|
+
const doneEntries = useMemo(() => Array.from(done.entries()), [done]);
|
|
396
|
+
const backlogLimit = Math.max(0, BACKLOG_VISIBLE_LIMIT - inFlight.length);
|
|
397
|
+
const { visible: visibleBacklog, hasMore: hasMoreBacklog } = useMemo(
|
|
398
|
+
() => getVisibleEntries(backlogEntries, backlogLimit, showAllBacklog),
|
|
399
|
+
[backlogEntries, backlogLimit, showAllBacklog]
|
|
400
|
+
);
|
|
401
|
+
const { visible: visibleDone, hasMore: hasMoreDone } = useMemo(
|
|
402
|
+
() => getVisibleEntries(doneEntries, DONE_VISIBLE_LIMIT, showAllDone),
|
|
403
|
+
[doneEntries, showAllDone]
|
|
404
|
+
);
|
|
405
|
+
|
|
997
406
|
// Keyboard shortcuts for undo/redo (Cmd+Z / Cmd+Shift+Z)
|
|
998
407
|
useEffect(() => {
|
|
999
408
|
const handleKeyDown = async (e: KeyboardEvent) => {
|
|
@@ -1026,14 +435,22 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
|
|
|
1026
435
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
1027
436
|
}, [onUndo, onRedo, canUndo, canRedo]);
|
|
1028
437
|
|
|
1029
|
-
// Build a
|
|
1030
|
-
const
|
|
1031
|
-
|
|
1032
|
-
const
|
|
1033
|
-
|
|
1034
|
-
|
|
438
|
+
// Build a map of epic IDs to their in-flight items
|
|
439
|
+
const inFlightByEpic = useMemo(() => {
|
|
440
|
+
const map = new Map<number, InFlightItem[]>();
|
|
441
|
+
for (const item of inFlight) {
|
|
442
|
+
const epicId = item.parent_id || item.epic_id;
|
|
443
|
+
if (epicId) {
|
|
444
|
+
const existing = map.get(epicId);
|
|
445
|
+
if (existing) {
|
|
446
|
+
existing.push(item);
|
|
447
|
+
} else {
|
|
448
|
+
map.set(epicId, [item]);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
1035
451
|
}
|
|
1036
|
-
|
|
452
|
+
return map;
|
|
453
|
+
}, [inFlight]);
|
|
1037
454
|
|
|
1038
455
|
// Board-level animation state - tracks which item is playing the completion animation
|
|
1039
456
|
const [internalAnimatingItemId, setInternalAnimatingItemId] = useState<number | null>(null);
|
|
@@ -1042,24 +459,8 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
|
|
|
1042
459
|
// Merge internal (UI-driven) and external (CLI/DB-driven) animation triggers
|
|
1043
460
|
const animatingItemId = internalAnimatingItemId ?? externalAnimatingItemId ?? null;
|
|
1044
461
|
|
|
1045
|
-
//
|
|
1046
|
-
const itemStatusMap =
|
|
1047
|
-
const map = new Map<number, string>();
|
|
1048
|
-
for (const item of inFlight) {
|
|
1049
|
-
map.set(item.id, item.status);
|
|
1050
|
-
}
|
|
1051
|
-
for (const group of backlog.values()) {
|
|
1052
|
-
for (const item of group.items) {
|
|
1053
|
-
map.set(item.id, item.status);
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
for (const group of done.values()) {
|
|
1057
|
-
for (const item of group.items) {
|
|
1058
|
-
map.set(item.id, item.status);
|
|
1059
|
-
}
|
|
1060
|
-
}
|
|
1061
|
-
return map;
|
|
1062
|
-
}, [inFlight, backlog, done]);
|
|
462
|
+
// Use pre-built statusMap from data-bridge when available (avoids O(N) rebuild)
|
|
463
|
+
const itemStatusMap = externalStatusMap ?? new Map<number, string>();
|
|
1063
464
|
|
|
1064
465
|
// Wrapper for onStatusChange that intercepts "done" transitions to play animation first
|
|
1065
466
|
const handleStatusChangeWithAnimation = useCallback(async (id: number, newStatus: string) => {
|
|
@@ -1069,6 +470,12 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
|
|
|
1069
470
|
|
|
1070
471
|
// If transitioning to done from non-done status, play animation first in backlog/in-flight
|
|
1071
472
|
if (newStatus === 'done' && currentStatus !== 'done') {
|
|
473
|
+
// Flush any existing pending change before replacing (rapid acceptance race)
|
|
474
|
+
if (pendingStatusChangeRef.current) {
|
|
475
|
+
const { id: prevId, status: prevStatus } = pendingStatusChangeRef.current;
|
|
476
|
+
pendingStatusChangeRef.current = null;
|
|
477
|
+
onStatusChange(prevId, prevStatus);
|
|
478
|
+
}
|
|
1072
479
|
pendingStatusChangeRef.current = { id, status: newStatus };
|
|
1073
480
|
setInternalAnimatingItemId(id);
|
|
1074
481
|
return;
|
|
@@ -1094,10 +501,18 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
|
|
|
1094
501
|
}
|
|
1095
502
|
}, [onStatusChange, internalAnimatingItemId, externalAnimatingItemId, onExternalAnimationComplete]);
|
|
1096
503
|
|
|
504
|
+
// Map for O(1) lookup in drag overlay instead of O(N) find
|
|
505
|
+
const inFlightMap = useMemo(() => {
|
|
506
|
+
const map = new Map<number, InFlightItem>();
|
|
507
|
+
for (const item of inFlight) {
|
|
508
|
+
map.set(item.id, item);
|
|
509
|
+
}
|
|
510
|
+
return map;
|
|
511
|
+
}, [inFlight]);
|
|
512
|
+
|
|
1097
513
|
// Render function for the drag overlay
|
|
1098
514
|
const renderDragOverlay = useCallback((item: WorkItem) => {
|
|
1099
|
-
|
|
1100
|
-
const inFlightItem = inFlight.find(i => i.id === item.id);
|
|
515
|
+
const inFlightItem = inFlightMap.get(item.id);
|
|
1101
516
|
const epicTitle = inFlightItem?.epicTitle || null;
|
|
1102
517
|
const isInFlightCard = inFlightItem !== undefined;
|
|
1103
518
|
|
|
@@ -1109,141 +524,136 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
|
|
|
1109
524
|
isInFlight={isInFlightCard}
|
|
1110
525
|
/>
|
|
1111
526
|
);
|
|
1112
|
-
}, [
|
|
527
|
+
}, [inFlightMap]);
|
|
1113
528
|
|
|
1114
529
|
return (
|
|
1115
530
|
<DragProvider renderDragOverlay={renderDragOverlay} onRemoveFromEpic={onEpicAssign} onError={onError}>
|
|
1116
|
-
|
|
531
|
+
{/* height: --main-h (from AppShell ResizeObserver) minus page py-4 padding */}
|
|
532
|
+
<div className="flex gap-4 overflow-x-auto" style={{ height: 'calc(var(--main-h, 100vh) - 2rem)' }} data-testid="kanban-board">
|
|
1117
533
|
{/* Backlog Column */}
|
|
1118
|
-
<KanbanColumn title="Backlog" count={backlogCount} onAdd={onAddToBacklog} addDisabled={!usageAllowed}>
|
|
1119
|
-
{/* In Flight
|
|
1120
|
-
<
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
<
|
|
534
|
+
<KanbanColumn title="Backlog" count={backlogCount} onAdd={onAddToBacklog} addDisabled={!usageAllowed} scrollRef={backlogScrollRef}>
|
|
535
|
+
{/* Non-virtualized section: In Flight + divider (measured for scrollMargin) */}
|
|
536
|
+
<div ref={preBacklogRef}>
|
|
537
|
+
{/* In Flight Section - Drop Zone */}
|
|
538
|
+
<DropZone
|
|
539
|
+
targetStatus="in_progress"
|
|
540
|
+
onDrop={async (itemId, newStatus) => {
|
|
541
|
+
await handleStatusChangeWithAnimation(itemId, newStatus);
|
|
542
|
+
}}
|
|
543
|
+
className="rounded-lg mb-6 p-3 -m-3"
|
|
544
|
+
highlightClassName="ring-2 ring-[#819D9F] bg-[#819D9F]/10 dark:bg-[#819D9F]/20"
|
|
545
|
+
data-testid="in-flight-drop-zone"
|
|
546
|
+
>
|
|
547
|
+
{inFlight.length > 0 ? (
|
|
548
|
+
<div data-testid="in-flight-section" className="bg-[#e8f0f0] dark:bg-[#819D9F]/20 rounded-lg p-3 -m-1">
|
|
549
|
+
<div className="flex items-center gap-2 text-base font-medium text-[#5a7d7f] dark:text-[#a3bfc0] mb-3">
|
|
550
|
+
<img src="/in-flight-seagull.png" alt="" className="w-6 h-6 object-contain" />
|
|
551
|
+
<span>In Flight</span>
|
|
552
|
+
</div>
|
|
553
|
+
<div className="space-y-3">
|
|
554
|
+
{inFlight.map((item) => (
|
|
555
|
+
<DraggableCard key={item.id} item={item}>
|
|
556
|
+
<KanbanCard
|
|
557
|
+
item={item}
|
|
558
|
+
epicTitle={item.epicTitle}
|
|
559
|
+
showEpic={true}
|
|
560
|
+
isInFlight={true}
|
|
561
|
+
onTitleSave={onTitleSave}
|
|
562
|
+
onStatusChange={handleStatusChangeWithAnimation}
|
|
563
|
+
onReject={onReject}
|
|
564
|
+
onRestart={onRestart}
|
|
565
|
+
onTriggerClaude={onTriggerClaude}
|
|
566
|
+
hasActiveSession={activeSessionIds?.has(String(item.id))}
|
|
567
|
+
onOpenSession={onOpenSession}
|
|
568
|
+
onCloseSession={onCloseSession}
|
|
569
|
+
usageAllowed={usageAllowed}
|
|
570
|
+
isCompletingAnimation={animatingItemId === item.id}
|
|
571
|
+
onAnimationComplete={handleAnimationComplete}
|
|
572
|
+
/>
|
|
573
|
+
</DraggableCard>
|
|
574
|
+
))}
|
|
575
|
+
</div>
|
|
1134
576
|
</div>
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
item={item}
|
|
1140
|
-
epicTitle={item.epicTitle}
|
|
1141
|
-
showEpic={true}
|
|
1142
|
-
isInFlight={true}
|
|
1143
|
-
onTitleSave={onTitleSave}
|
|
1144
|
-
onStatusChange={handleStatusChangeWithAnimation}
|
|
1145
|
-
onReject={onReject}
|
|
1146
|
-
onTriggerClaude={onTriggerClaude}
|
|
1147
|
-
hasActiveSession={activeSessions?.has(String(item.id))}
|
|
1148
|
-
onOpenSession={onOpenSession}
|
|
1149
|
-
usageAllowed={usageAllowed}
|
|
1150
|
-
isCompletingAnimation={animatingItemId === item.id}
|
|
1151
|
-
onAnimationComplete={handleAnimationComplete}
|
|
1152
|
-
/>
|
|
1153
|
-
</DraggableCard>
|
|
1154
|
-
))}
|
|
577
|
+
) : (
|
|
578
|
+
<div className="flex items-center gap-2 text-base font-medium text-zinc-400 dark:text-zinc-500 py-3">
|
|
579
|
+
<img src="/in-flight-seagull.png" alt="" className="w-6 h-6 object-contain opacity-50" />
|
|
580
|
+
<span>Drop here to start work</span>
|
|
1155
581
|
</div>
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
<div className="flex items-center gap-1.5 text-xs font-medium text-zinc-400 dark:text-zinc-500 py-2">
|
|
1159
|
-
<span>🔥</span>
|
|
1160
|
-
<span>Drop here to start work</span>
|
|
1161
|
-
</div>
|
|
1162
|
-
)}
|
|
1163
|
-
</DropZone>
|
|
582
|
+
)}
|
|
583
|
+
</DropZone>
|
|
1164
584
|
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
585
|
+
{/* Divider if both sections have content */}
|
|
586
|
+
{(inFlight.length > 0 || backlog.size > 0) && (
|
|
587
|
+
<hr className="border-zinc-300 dark:border-zinc-700 my-6" />
|
|
588
|
+
)}
|
|
589
|
+
</div>
|
|
1169
590
|
|
|
1170
|
-
{/* Backlog Section - Drop Zone with Reordering */}
|
|
591
|
+
{/* Virtualized Backlog Section - Drop Zone with Reordering */}
|
|
1171
592
|
<BacklogDropZoneWrapper
|
|
1172
593
|
backlog={backlog}
|
|
1173
594
|
onStatusChange={handleStatusChangeWithAnimation}
|
|
1174
595
|
onOrderChange={onOrderChange}
|
|
1175
596
|
>
|
|
1176
|
-
<
|
|
1177
|
-
{
|
|
1178
|
-
{
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
))}
|
|
1200
|
-
|
|
1201
|
-
{backlog.size === 0 && (
|
|
1202
|
-
<p className="text-sm text-zinc-500 text-center py-4">Drop items here for backlog</p>
|
|
1203
|
-
)}
|
|
1204
|
-
</div>
|
|
597
|
+
<VirtualizedEpicList
|
|
598
|
+
entries={visibleBacklog}
|
|
599
|
+
scrollRef={backlogScrollRef}
|
|
600
|
+
scrollMargin={backlogScrollMargin}
|
|
601
|
+
hasMore={hasMoreBacklog}
|
|
602
|
+
onShowMore={() => setShowAllBacklog(true)}
|
|
603
|
+
emptyMessage={backlog.size === 0 ? 'Drop items here for backlog' : undefined}
|
|
604
|
+
inFlightByEpic={inFlightByEpic}
|
|
605
|
+
onTitleSave={onTitleSave}
|
|
606
|
+
onStatusChange={handleStatusChangeWithAnimation}
|
|
607
|
+
onReject={onReject}
|
|
608
|
+
onRestart={onRestart}
|
|
609
|
+
onEpicAssign={onEpicAssign}
|
|
610
|
+
onOrderChange={onOrderChange}
|
|
611
|
+
onTriggerClaude={onTriggerClaude}
|
|
612
|
+
activeSessionIds={activeSessionIds}
|
|
613
|
+
onOpenSession={onOpenSession}
|
|
614
|
+
onCloseSession={onCloseSession}
|
|
615
|
+
onError={onError}
|
|
616
|
+
usageAllowed={usageAllowed}
|
|
617
|
+
animatingItemId={animatingItemId}
|
|
618
|
+
onAnimationComplete={handleAnimationComplete}
|
|
619
|
+
/>
|
|
1205
620
|
</BacklogDropZoneWrapper>
|
|
1206
621
|
|
|
1207
622
|
{backlogCount === 0 && inFlight.length === 0 && (
|
|
1208
|
-
<p className="text-
|
|
623
|
+
<p className="text-base text-zinc-500 text-center py-4">No items in backlog</p>
|
|
1209
624
|
)}
|
|
1210
625
|
</KanbanColumn>
|
|
1211
626
|
|
|
1212
627
|
{/* Done Column */}
|
|
1213
|
-
<KanbanColumn title="Done" count={doneCount}>
|
|
628
|
+
<KanbanColumn title="Done" count={doneCount} scrollRef={doneScrollRef}>
|
|
1214
629
|
<DropZone
|
|
1215
630
|
targetStatus="done"
|
|
1216
631
|
onDrop={async (itemId, newStatus) => {
|
|
1217
632
|
await handleStatusChangeWithAnimation(itemId, newStatus);
|
|
1218
633
|
}}
|
|
1219
|
-
className="rounded-lg p-
|
|
1220
|
-
highlightClassName="ring-2 ring-
|
|
634
|
+
className="rounded-lg p-3 -m-3 min-h-[100px]"
|
|
635
|
+
highlightClassName="ring-2 ring-zinc-400 bg-zinc-100/50 dark:bg-zinc-800/50"
|
|
1221
636
|
data-testid="done-drop-zone"
|
|
1222
637
|
>
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
))}
|
|
1240
|
-
|
|
1241
|
-
{doneCount === 0 && (
|
|
1242
|
-
<p className="text-sm text-zinc-500 text-center py-4">Drop here to mark complete</p>
|
|
1243
|
-
)}
|
|
638
|
+
<VirtualizedEpicList
|
|
639
|
+
entries={visibleDone}
|
|
640
|
+
scrollRef={doneScrollRef}
|
|
641
|
+
hasMore={hasMoreDone}
|
|
642
|
+
onShowMore={() => setShowAllDone(true)}
|
|
643
|
+
emptyMessage={doneCount === 0 ? 'Drop here to mark complete' : undefined}
|
|
644
|
+
isDraggable={true}
|
|
645
|
+
onTitleSave={onTitleSave}
|
|
646
|
+
onStatusChange={handleStatusChangeWithAnimation}
|
|
647
|
+
onReject={onReject}
|
|
648
|
+
activeSessionIds={activeSessionIds}
|
|
649
|
+
onOpenSession={onOpenSession}
|
|
650
|
+
onCloseSession={onCloseSession}
|
|
651
|
+
onError={onError}
|
|
652
|
+
usageAllowed={usageAllowed}
|
|
653
|
+
/>
|
|
1244
654
|
</DropZone>
|
|
1245
655
|
</KanbanColumn>
|
|
1246
656
|
</div>
|
|
1247
657
|
</DragProvider>
|
|
1248
658
|
);
|
|
1249
|
-
}
|
|
659
|
+
});
|