jettypod 4.4.120 → 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 +2 -1
- 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 +54 -49
- package/apps/dashboard/app/demo/gates/page.tsx +3 -5
- package/apps/dashboard/app/design-system/page.tsx +1 -1
- package/apps/dashboard/app/globals.css +74 -2
- package/apps/dashboard/app/install-claude/page.tsx +3 -5
- package/apps/dashboard/app/login/page.tsx +17 -20
- package/apps/dashboard/app/page.tsx +101 -48
- package/apps/dashboard/app/settings/page.tsx +60 -12
- package/apps/dashboard/app/signup/page.tsx +14 -17
- 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 +12 -15
- package/apps/dashboard/app/work/[id]/page.tsx +90 -75
- package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
- package/apps/dashboard/components/AppShell.tsx +70 -61
- package/apps/dashboard/components/CardMenu.tsx +0 -1
- package/apps/dashboard/components/ClaudePanel.tsx +541 -283
- package/apps/dashboard/components/ClaudePanelInput.tsx +23 -4
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +1 -5
- package/apps/dashboard/components/CopyableId.tsx +1 -2
- package/apps/dashboard/components/DetailReviewActions.tsx +11 -20
- package/apps/dashboard/components/DragContext.tsx +132 -62
- package/apps/dashboard/components/DraggableCard.tsx +3 -5
- package/apps/dashboard/components/DropZone.tsx +5 -6
- package/apps/dashboard/components/EditableDetailDescription.tsx +6 -12
- package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
- package/apps/dashboard/components/EditableTitle.tsx +0 -1
- package/apps/dashboard/components/ElapsedTimer.tsx +15 -3
- package/apps/dashboard/components/EpicGroup.tsx +100 -70
- package/apps/dashboard/components/GateCard.tsx +0 -1
- package/apps/dashboard/components/GateChoiceCard.tsx +1 -2
- package/apps/dashboard/components/InstallClaudeScreen.tsx +1 -5
- package/apps/dashboard/components/JettyLoader.tsx +0 -1
- package/apps/dashboard/components/KanbanBoard.tsx +319 -173
- package/apps/dashboard/components/KanbanCard.tsx +341 -107
- package/apps/dashboard/components/LazyCard.tsx +62 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +0 -1
- package/apps/dashboard/components/MainNav.tsx +24 -25
- package/apps/dashboard/components/MessageBlock.tsx +93 -16
- package/apps/dashboard/components/ModeStartCard.tsx +0 -1
- package/apps/dashboard/components/OnboardingWelcome.tsx +0 -1
- package/apps/dashboard/components/PlaceholderCard.tsx +0 -1
- package/apps/dashboard/components/ProjectSwitcher.tsx +20 -20
- package/apps/dashboard/components/PrototypeTimeline.tsx +47 -26
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +308 -223
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +303 -160
- package/apps/dashboard/components/ReviewFooter.tsx +12 -14
- package/apps/dashboard/components/SessionList.tsx +0 -1
- package/apps/dashboard/components/SubscribeContent.tsx +40 -11
- package/apps/dashboard/components/TestTree.tsx +1 -2
- package/apps/dashboard/components/TipCard.tsx +2 -4
- package/apps/dashboard/components/Toast.tsx +0 -1
- package/apps/dashboard/components/TypeIcon.tsx +7 -8
- package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +5 -17
- package/apps/dashboard/components/WelcomeScreen.tsx +2 -6
- package/apps/dashboard/components/WorkItemHeader.tsx +0 -1
- package/apps/dashboard/components/WorkItemTree.tsx +2 -4
- package/apps/dashboard/components/settings/AccountSection.tsx +27 -13
- 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 +20 -73
- package/apps/dashboard/components/settings/GeneralSection.tsx +137 -26
- package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
- package/apps/dashboard/components/settings/SettingsLayout.tsx +0 -1
- package/apps/dashboard/components/ui/Button.tsx +1 -1
- package/apps/dashboard/components/ui/Input.tsx +1 -1
- package/apps/dashboard/components.json +1 -1
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +611 -358
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +0 -1
- package/apps/dashboard/contexts/UsageContext.tsx +62 -31
- package/apps/dashboard/dev.sh +35 -0
- package/apps/dashboard/eslint.config.mjs +9 -9
- package/apps/dashboard/hooks/useWebSocket.ts +138 -83
- package/apps/dashboard/index.html +73 -0
- package/apps/dashboard/lib/data-bridge.ts +722 -0
- package/apps/dashboard/lib/db.ts +69 -1302
- 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 +226 -26
- package/apps/dashboard/lib/proof-run.ts +495 -0
- package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
- 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 +253 -122
- 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 +3 -3
- package/apps/dashboard/next-env.d.ts +1 -1
- package/apps/dashboard/package.json +21 -33
- package/apps/dashboard/public/bug-icon.png +0 -0
- package/apps/dashboard/public/buoy-icon.png +0 -0
- package/apps/dashboard/public/in-flight-seagull.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/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 +167 -30
- 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/jettypod.js +96 -4
- package/lib/bdd-preflight.js +96 -0
- package/lib/merge-lock.js +111 -253
- package/lib/migrations/030-rejection-round-columns.js +54 -0
- package/lib/migrations/031-session-isolation-index.js +17 -0
- package/lib/work-commands/index.js +58 -16
- package/lib/work-tracking/index.js +108 -8
- package/package.json +1 -1
- package/skills-templates/bug-mode/SKILL.md +43 -1
- package/skills-templates/chore-mode/SKILL.md +40 -1
- package/skills-templates/design-system-selection/SKILL.md +273 -0
- package/skills-templates/epic-planning/SKILL.md +14 -0
- package/skills-templates/feature-planning/SKILL.md +90 -1
- package/skills-templates/production-mode/SKILL.md +20 -0
- package/skills-templates/simple-improvement/SKILL.md +39 -2
- package/skills-templates/speed-mode/SKILL.md +10 -15
- package/skills-templates/stable-mode/SKILL.md +47 -0
- package/apps/dashboard/README.md +0 -36
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -446
- package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -280
- 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 -525
- 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]/route.ts +0 -35
- package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -63
- package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
- package/apps/dashboard/app/layout.tsx +0 -55
- package/apps/dashboard/components/UpgradeBanner.tsx +0 -30
- package/apps/dashboard/electron/ipc-handlers.js +0 -1026
- package/apps/dashboard/electron/main.js +0 -2306
- package/apps/dashboard/electron/preload.js +0 -125
- package/apps/dashboard/electron/session-manager.js +0 -163
- package/apps/dashboard/electron-builder.config.js +0 -357
- package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
- package/apps/dashboard/lib/backlog-parser.ts +0 -50
- package/apps/dashboard/lib/claude-process-manager.ts +0 -529
- package/apps/dashboard/lib/db-bridge.ts +0 -283
- 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 -66
- package/apps/dashboard/postcss.config.mjs +0 -7
- package/apps/dashboard/public/bug-icon.svg +0 -9
- package/apps/dashboard/public/buoy-icon.svg +0 -9
- package/apps/dashboard/public/file.svg +0 -1
- package/apps/dashboard/public/globe.svg +0 -1
- package/apps/dashboard/public/in-flight-seagull.svg +0 -9
- package/apps/dashboard/public/next.svg +0 -1
- package/apps/dashboard/public/pier-icon.svg +0 -14
- package/apps/dashboard/public/star-icon.svg +0 -9
- package/apps/dashboard/public/vercel.svg +0 -1
- package/apps/dashboard/public/window.svg +0 -1
- package/apps/dashboard/public/wrench-icon.svg +0 -9
- package/apps/dashboard/scripts/download-node.js +0 -104
- package/apps/dashboard/scripts/upload-to-r2.js +0 -89
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
'use client';
|
|
2
1
|
|
|
3
|
-
import { useState, memo } from 'react';
|
|
4
|
-
import Link from '
|
|
5
|
-
import { useRouter } from 'next/navigation';
|
|
6
|
-
import { m } from 'framer-motion';
|
|
2
|
+
import { useState, useRef, useCallback, memo } from 'react';
|
|
3
|
+
import { Link, useNavigate } from 'react-router-dom';
|
|
7
4
|
import type { WorkItem } from '@/lib/db';
|
|
5
|
+
import { prefetch } from '@/lib/data-bridge';
|
|
8
6
|
import { EditableTitle } from './EditableTitle';
|
|
9
7
|
import { CardMenu } from './CardMenu';
|
|
10
8
|
import { CopyableId } from './CopyableId';
|
|
@@ -15,6 +13,26 @@ import { MODE_LABELS } from '@/lib/constants';
|
|
|
15
13
|
import { TypeIcon } from './TypeIcon';
|
|
16
14
|
import { shadow } from '@/lib/shadows';
|
|
17
15
|
|
|
16
|
+
function formatRejectionTime(isoString: string): string {
|
|
17
|
+
try {
|
|
18
|
+
const date = new Date(isoString);
|
|
19
|
+
const now = new Date();
|
|
20
|
+
const diffMs = now.getTime() - date.getTime();
|
|
21
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
22
|
+
const diffHours = Math.floor(diffMs / 3600000);
|
|
23
|
+
const diffDays = Math.floor(diffMs / 86400000);
|
|
24
|
+
|
|
25
|
+
if (diffMins < 1) return 'just now';
|
|
26
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
27
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
28
|
+
if (diffDays === 1) return 'yesterday';
|
|
29
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
30
|
+
return date.toLocaleDateString();
|
|
31
|
+
} catch {
|
|
32
|
+
return '';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
18
36
|
function getModeLabel(item: WorkItem): string {
|
|
19
37
|
if (!item.mode) return '';
|
|
20
38
|
const base = MODE_LABELS[item.mode]?.label || item.mode;
|
|
@@ -33,6 +51,122 @@ function SessionIndicatorIcon({ className }: { className?: string }) {
|
|
|
33
51
|
);
|
|
34
52
|
}
|
|
35
53
|
|
|
54
|
+
interface RejectionHistoryEntry {
|
|
55
|
+
round: number;
|
|
56
|
+
reason: string;
|
|
57
|
+
at: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function RejectionTimeline({ item, allChores, allBugs, isDone }: { item: WorkItem; allChores: WorkItem[]; allBugs: WorkItem[]; isDone: boolean }) {
|
|
61
|
+
let history: RejectionHistoryEntry[] = [];
|
|
62
|
+
try {
|
|
63
|
+
history = item.rejection_history ? JSON.parse(item.rejection_history) : [];
|
|
64
|
+
} catch {
|
|
65
|
+
// Corrupt JSON — degrade gracefully, show segments without reasons
|
|
66
|
+
}
|
|
67
|
+
const allChildren = [...allChores, ...allBugs];
|
|
68
|
+
|
|
69
|
+
// Build timeline segments
|
|
70
|
+
const segments: Array<{ label: string; round: number | null; reason: string | null; isLatest: boolean; children: WorkItem[] }> = [];
|
|
71
|
+
|
|
72
|
+
// Original segment (children with null rejection_round)
|
|
73
|
+
const originalChildren = allChildren.filter(c => c.rejection_round == null);
|
|
74
|
+
if (originalChildren.length > 0) {
|
|
75
|
+
segments.push({ label: 'Original', round: null, reason: null, isLatest: false, children: originalChildren });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Rejection round segments
|
|
79
|
+
for (let r = 1; r <= (item.rejection_count || 0); r++) {
|
|
80
|
+
const roundChildren = allChildren.filter(c => c.rejection_round === r);
|
|
81
|
+
const entry = history.find(h => h.round === r);
|
|
82
|
+
segments.push({
|
|
83
|
+
label: `Rejection #${r}`,
|
|
84
|
+
round: r,
|
|
85
|
+
reason: entry?.reason || null,
|
|
86
|
+
isLatest: r === item.rejection_count,
|
|
87
|
+
children: roundChildren,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div className="relative pl-[18px]">
|
|
93
|
+
{segments.map((seg, idx) => {
|
|
94
|
+
const isLast = idx === segments.length - 1;
|
|
95
|
+
return (
|
|
96
|
+
<div key={seg.label} className="relative" style={{ paddingBottom: isLast ? 0 : 4 }}>
|
|
97
|
+
{/* Vertical connector line */}
|
|
98
|
+
{!isLast && (
|
|
99
|
+
<div
|
|
100
|
+
className="absolute border-l-2 border-zinc-200 dark:border-zinc-700"
|
|
101
|
+
style={{ left: -12, top: 14, bottom: -4 }}
|
|
102
|
+
/>
|
|
103
|
+
)}
|
|
104
|
+
{/* Node marker + label */}
|
|
105
|
+
<div className="relative flex items-center gap-2 py-1">
|
|
106
|
+
<div
|
|
107
|
+
className="absolute rounded-full"
|
|
108
|
+
style={{
|
|
109
|
+
left: -16, top: '50%', transform: 'translateY(-50%)',
|
|
110
|
+
width: 10, height: 10,
|
|
111
|
+
background: seg.round === null ? '#819D9F' : '#ef4444',
|
|
112
|
+
border: '2px solid var(--card, #27272a)',
|
|
113
|
+
zIndex: 1,
|
|
114
|
+
...(seg.isLatest ? { boxShadow: '0 0 0 3px rgba(239, 68, 68, 0.2)' } : {}),
|
|
115
|
+
}}
|
|
116
|
+
/>
|
|
117
|
+
<span className={`text-[11px] font-semibold uppercase tracking-wide ${
|
|
118
|
+
seg.round === null ? 'text-[#819D9F]' : 'text-red-400 dark:text-red-400'
|
|
119
|
+
}`}>{seg.label}</span>
|
|
120
|
+
{seg.reason && (
|
|
121
|
+
<span className="text-[11px] italic text-zinc-500 dark:text-zinc-500 truncate max-w-[180px]">
|
|
122
|
+
{seg.reason}
|
|
123
|
+
</span>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
{/* Children under this segment */}
|
|
127
|
+
<div className="py-0.5">
|
|
128
|
+
{seg.children.map((child) => {
|
|
129
|
+
const isComplete = child.status === 'done';
|
|
130
|
+
const isBug = child.type === 'bug';
|
|
131
|
+
return (
|
|
132
|
+
<Link
|
|
133
|
+
key={child.id}
|
|
134
|
+
to={`/work/${child.id}`}
|
|
135
|
+
viewTransition
|
|
136
|
+
className={`block py-1.5 px-2.5 text-base rounded transition-colors duration-200 ease-out ${
|
|
137
|
+
isComplete
|
|
138
|
+
? 'bg-zinc-100 dark:bg-zinc-800/50'
|
|
139
|
+
: 'hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
|
140
|
+
}`}
|
|
141
|
+
>
|
|
142
|
+
<div className="flex items-center gap-3">
|
|
143
|
+
<span className={`font-mono text-xs ${isComplete ? 'text-zinc-500' : 'text-zinc-400'}`}>#{child.id}</span>
|
|
144
|
+
{isBug && <TypeIcon type="bug" />}
|
|
145
|
+
{!isDone && !isBug && child.mode && MODE_LABELS[child.mode] && (
|
|
146
|
+
<span className={`px-1 py-0.5 rounded text-[10px] ${MODE_LABELS[child.mode].color}`}>
|
|
147
|
+
{getModeLabel(child)}
|
|
148
|
+
</span>
|
|
149
|
+
)}
|
|
150
|
+
<span className={`truncate ${
|
|
151
|
+
isComplete ? 'text-zinc-500' : 'text-zinc-700 dark:text-zinc-300'
|
|
152
|
+
}`}>
|
|
153
|
+
{child.title || <span className="text-zinc-400 italic">(Untitled)</span>}
|
|
154
|
+
</span>
|
|
155
|
+
</div>
|
|
156
|
+
</Link>
|
|
157
|
+
);
|
|
158
|
+
})}
|
|
159
|
+
{seg.children.length === 0 && (
|
|
160
|
+
<span className="text-xs text-zinc-500 italic pl-2.5">No items</span>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
})}
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
36
170
|
export interface KanbanCardProps {
|
|
37
171
|
item: WorkItem;
|
|
38
172
|
epicTitle?: string | null;
|
|
@@ -58,7 +192,8 @@ export const KanbanCard = memo(function KanbanCard({ item, epicTitle, showEpic =
|
|
|
58
192
|
const [showRejectInput, setShowRejectInput] = useState(false);
|
|
59
193
|
const [rejectReason, setRejectReason] = useState('');
|
|
60
194
|
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
|
61
|
-
const
|
|
195
|
+
const [rejectionExpanded, setRejectionExpanded] = useState(false);
|
|
196
|
+
const navigate = useNavigate();
|
|
62
197
|
|
|
63
198
|
const handleOpenSession = (e: React.MouseEvent) => {
|
|
64
199
|
e.stopPropagation(); // Prevent card navigation
|
|
@@ -131,10 +266,25 @@ export const KanbanCard = memo(function KanbanCard({ item, epicTitle, showEpic =
|
|
|
131
266
|
const incompleteBugs = allBugs.filter(b => b.status !== 'done');
|
|
132
267
|
const hasBugs = allBugs.length > 0;
|
|
133
268
|
|
|
269
|
+
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
270
|
+
|
|
134
271
|
const handleCardClick = () => {
|
|
135
|
-
|
|
272
|
+
navigate(`/work/${item.id}`);
|
|
136
273
|
};
|
|
137
274
|
|
|
275
|
+
const handleMouseEnter = useCallback(() => {
|
|
276
|
+
hoverTimer.current = setTimeout(() => {
|
|
277
|
+
prefetch.workItem(item.id);
|
|
278
|
+
}, 100);
|
|
279
|
+
}, [item.id]);
|
|
280
|
+
|
|
281
|
+
const handleMouseLeave = useCallback(() => {
|
|
282
|
+
if (hoverTimer.current) {
|
|
283
|
+
clearTimeout(hoverTimer.current);
|
|
284
|
+
hoverTimer.current = null;
|
|
285
|
+
}
|
|
286
|
+
}, []);
|
|
287
|
+
|
|
138
288
|
const handleTitleSave = async (id: number, newTitle: string) => {
|
|
139
289
|
if (onTitleSave) {
|
|
140
290
|
await onTitleSave(id, newTitle);
|
|
@@ -150,27 +300,23 @@ export const KanbanCard = memo(function KanbanCard({ item, epicTitle, showEpic =
|
|
|
150
300
|
|
|
151
301
|
const isDone = item.status === 'done';
|
|
152
302
|
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
hoverBoxShadow: shadow.lg,
|
|
158
|
-
};
|
|
303
|
+
const cardStyles = {
|
|
304
|
+
className: 'bg-white dark:bg-zinc-800',
|
|
305
|
+
boxShadow: shadow.sm,
|
|
306
|
+
hoverBoxShadow: shadow.lg,
|
|
159
307
|
};
|
|
160
308
|
|
|
161
|
-
const cardStyles = getCardStyles();
|
|
162
|
-
|
|
163
309
|
const cardContent = (
|
|
164
310
|
<WaveCompletionAnimation isPlaying={isCompletingAnimation} onComplete={onAnimationComplete || (() => {})}>
|
|
165
311
|
<div
|
|
166
|
-
className={`rounded-xl overflow-hidden transition-[
|
|
167
|
-
style={{ boxShadow: cardStyles.boxShadow }}
|
|
168
|
-
onMouseEnter={(e) => { e.currentTarget.style.boxShadow = cardStyles.hoverBoxShadow; }}
|
|
169
|
-
onMouseLeave={(e) => { e.currentTarget.style.boxShadow = cardStyles.boxShadow; }}
|
|
312
|
+
className={`kanban-card rounded-xl overflow-hidden transition-[background-color,opacity] duration-200 ease-out ${cardStyles.className}`}
|
|
313
|
+
style={{ boxShadow: cardStyles.boxShadow, '--hover-shadow': cardStyles.hoverBoxShadow } as React.CSSProperties}
|
|
170
314
|
data-testid={`kanban-card-${item.id}`}>
|
|
171
315
|
<div
|
|
172
316
|
onClick={handleCardClick}
|
|
173
|
-
|
|
317
|
+
onMouseEnter={handleMouseEnter}
|
|
318
|
+
onMouseLeave={handleMouseLeave}
|
|
319
|
+
className={`block ${isDone ? 'pt-4 px-4 pb-3' : 'p-4'} cursor-pointer`}
|
|
174
320
|
>
|
|
175
321
|
<div className="flex items-start gap-3">
|
|
176
322
|
<span className="text-base flex-shrink-0"><TypeIcon type={item.type} /></span>
|
|
@@ -190,7 +336,7 @@ export const KanbanCard = memo(function KanbanCard({ item, epicTitle, showEpic =
|
|
|
190
336
|
<SessionIndicatorIcon className="w-4 h-4" />
|
|
191
337
|
</button>
|
|
192
338
|
)}
|
|
193
|
-
<span className="text-base font-medium text-zinc-900 dark:text-zinc-100">
|
|
339
|
+
<span className="text-base font-medium leading-snug text-zinc-900 dark:text-zinc-100">
|
|
194
340
|
{item.title || <span className="text-zinc-400 italic">(Untitled)</span>}
|
|
195
341
|
</span>
|
|
196
342
|
</div>
|
|
@@ -219,6 +365,20 @@ export const KanbanCard = memo(function KanbanCard({ item, epicTitle, showEpic =
|
|
|
219
365
|
</div>
|
|
220
366
|
{isReviewable ? (
|
|
221
367
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
|
368
|
+
{item.type === 'feature' && (
|
|
369
|
+
<Button
|
|
370
|
+
onClick={(e: React.MouseEvent) => {
|
|
371
|
+
e.stopPropagation();
|
|
372
|
+
navigate(`/work/${item.id}/proof`);
|
|
373
|
+
}}
|
|
374
|
+
variant="secondary"
|
|
375
|
+
size="xs"
|
|
376
|
+
aria-label="Launch QA proof dashboard"
|
|
377
|
+
data-testid={`qa-button-${item.id}`}
|
|
378
|
+
>
|
|
379
|
+
QA
|
|
380
|
+
</Button>
|
|
381
|
+
)}
|
|
222
382
|
{item.status !== 'done' && onStatusChange && (
|
|
223
383
|
<Button
|
|
224
384
|
onClick={handleAccept}
|
|
@@ -244,12 +404,6 @@ export const KanbanCard = memo(function KanbanCard({ item, epicTitle, showEpic =
|
|
|
244
404
|
</div>
|
|
245
405
|
) : item.rejection_reason && !isDone ? (
|
|
246
406
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
|
247
|
-
<span
|
|
248
|
-
className="text-xs px-2 py-1 rounded bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
|
249
|
-
data-testid={`rejected-badge-${item.id}`}
|
|
250
|
-
>
|
|
251
|
-
Rejected
|
|
252
|
-
</span>
|
|
253
407
|
{!hasActiveSession && onRestart && (
|
|
254
408
|
<Button
|
|
255
409
|
onClick={handleRestart}
|
|
@@ -288,8 +442,8 @@ export const KanbanCard = memo(function KanbanCard({ item, epicTitle, showEpic =
|
|
|
288
442
|
{showEpic && epicTitle && (() => {
|
|
289
443
|
const epicId = item.parent_id || item.epic_id;
|
|
290
444
|
return epicId ? (
|
|
291
|
-
<Link
|
|
292
|
-
|
|
445
|
+
<Link to={`/work/${epicId}`}
|
|
446
|
+
viewTransition
|
|
293
447
|
onClick={(e) => e.stopPropagation()}
|
|
294
448
|
className="group/epic text-sm text-zinc-400 dark:text-zinc-500 mt-2 flex items-center gap-1.5 hover:text-zinc-600 dark:hover:text-zinc-300"
|
|
295
449
|
>
|
|
@@ -370,15 +524,98 @@ export const KanbanCard = memo(function KanbanCard({ item, epicTitle, showEpic =
|
|
|
370
524
|
</div>
|
|
371
525
|
</div>
|
|
372
526
|
)}
|
|
373
|
-
{/*
|
|
374
|
-
{item.rejection_reason && (
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
527
|
+
{/* Rejection banner — hidden for done cards */}
|
|
528
|
+
{item.rejection_reason && !isDone && (() => {
|
|
529
|
+
let history: RejectionHistoryEntry[] = [];
|
|
530
|
+
try {
|
|
531
|
+
history = item.rejection_history ? JSON.parse(item.rejection_history) : [];
|
|
532
|
+
} catch { /* degrade gracefully */ }
|
|
533
|
+
const currentRound = item.rejection_count || 1;
|
|
534
|
+
const hasMultipleRounds = history.length > 1;
|
|
535
|
+
|
|
536
|
+
return (
|
|
537
|
+
<div>
|
|
538
|
+
<div
|
|
539
|
+
onClick={(e) => {
|
|
540
|
+
e.stopPropagation();
|
|
541
|
+
if (hasMultipleRounds) setRejectionExpanded(!rejectionExpanded);
|
|
542
|
+
}}
|
|
543
|
+
className={`bg-red-50 dark:bg-red-950/40 ${hasMultipleRounds ? 'cursor-pointer' : ''}`}
|
|
544
|
+
style={{ padding: '10px 16px' }}
|
|
545
|
+
data-testid={`rejection-banner-${item.id}`}
|
|
546
|
+
>
|
|
547
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
|
|
548
|
+
<span className="text-red-700 dark:text-red-400" style={{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em' }}>
|
|
549
|
+
Rejected
|
|
550
|
+
</span>
|
|
551
|
+
<span className="bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-400" style={{ fontSize: '11px', padding: '1px 8px', borderRadius: '12px', fontWeight: 600 }}>
|
|
552
|
+
Round {currentRound}
|
|
553
|
+
</span>
|
|
554
|
+
{hasMultipleRounds && (
|
|
555
|
+
<span className="text-red-600 dark:text-red-400" style={{ fontSize: '11px', opacity: 0.5, marginLeft: 'auto' }}>
|
|
556
|
+
{rejectionExpanded ? '▲' : '▼'}
|
|
557
|
+
</span>
|
|
558
|
+
)}
|
|
559
|
+
</div>
|
|
560
|
+
<div className="text-red-700/75 dark:text-red-400/75" style={{ fontSize: '14px', lineHeight: 1.4 }}>
|
|
561
|
+
{item.rejection_reason}
|
|
562
|
+
</div>
|
|
563
|
+
</div>
|
|
564
|
+
{/* Expanded stacked history */}
|
|
565
|
+
{rejectionExpanded && hasMultipleRounds && (
|
|
566
|
+
<div
|
|
567
|
+
onClick={(e) => e.stopPropagation()}
|
|
568
|
+
className="bg-red-50 dark:bg-red-950/40"
|
|
569
|
+
style={{ padding: '12px 16px' }}
|
|
570
|
+
>
|
|
571
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' }}>
|
|
572
|
+
<span className="text-red-700 dark:text-red-400" style={{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em' }}>
|
|
573
|
+
Rejections
|
|
574
|
+
</span>
|
|
575
|
+
<span className="bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-400" style={{ fontSize: '11px', padding: '1px 8px', borderRadius: '12px', fontWeight: 600 }}>
|
|
576
|
+
{history.length}
|
|
577
|
+
</span>
|
|
578
|
+
</div>
|
|
579
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
580
|
+
{history.map((entry) => {
|
|
581
|
+
const isLatest = entry.round === currentRound;
|
|
582
|
+
return (
|
|
583
|
+
<div key={entry.round} style={{ display: 'flex', gap: '10px', alignItems: 'flex-start' }}>
|
|
584
|
+
<span
|
|
585
|
+
className={isLatest
|
|
586
|
+
? 'bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-400'
|
|
587
|
+
: 'bg-red-100/60 dark:bg-red-900/20 text-red-600/60 dark:text-red-400/50'
|
|
588
|
+
}
|
|
589
|
+
style={{
|
|
590
|
+
fontSize: '11px',
|
|
591
|
+
fontWeight: 600,
|
|
592
|
+
padding: '2px 8px',
|
|
593
|
+
borderRadius: '8px',
|
|
594
|
+
flexShrink: 0,
|
|
595
|
+
marginTop: '1px',
|
|
596
|
+
}}
|
|
597
|
+
>
|
|
598
|
+
R{entry.round}
|
|
599
|
+
</span>
|
|
600
|
+
<div>
|
|
601
|
+
<div className="text-red-700/75 dark:text-red-400/75" style={{ fontSize: '14px', lineHeight: 1.4 }}>
|
|
602
|
+
{entry.reason}
|
|
603
|
+
</div>
|
|
604
|
+
{entry.at && (
|
|
605
|
+
<div className="text-red-600/40 dark:text-red-400/30" style={{ fontSize: '11px', marginTop: '2px' }}>
|
|
606
|
+
{formatRejectionTime(entry.at)}
|
|
607
|
+
</div>
|
|
608
|
+
)}
|
|
609
|
+
</div>
|
|
610
|
+
</div>
|
|
611
|
+
);
|
|
612
|
+
})}
|
|
613
|
+
</div>
|
|
614
|
+
</div>
|
|
615
|
+
)}
|
|
379
616
|
</div>
|
|
380
|
-
|
|
381
|
-
)}
|
|
617
|
+
);
|
|
618
|
+
})()}
|
|
382
619
|
{/* Show expandable section for features with chores or bugs */}
|
|
383
620
|
{(hasChores || hasBugs) && (
|
|
384
621
|
<div className={"border-t border-zinc-200 dark:border-zinc-700"}>
|
|
@@ -416,62 +653,70 @@ export const KanbanCard = memo(function KanbanCard({ item, epicTitle, showEpic =
|
|
|
416
653
|
</button>
|
|
417
654
|
{expanded && (
|
|
418
655
|
<div className="px-4 pb-3 space-y-1.5">
|
|
419
|
-
{
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
656
|
+
{item.rejection_count > 0 ? (
|
|
657
|
+
<RejectionTimeline item={item} allChores={allChores} allBugs={allBugs} isDone={isDone} />
|
|
658
|
+
) : (
|
|
659
|
+
<>
|
|
660
|
+
{allChores.map((chore) => {
|
|
661
|
+
const isComplete = chore.status === 'done';
|
|
662
|
+
return (
|
|
663
|
+
<Link
|
|
664
|
+
key={chore.id}
|
|
665
|
+
to={`/work/${chore.id}`}
|
|
666
|
+
viewTransition
|
|
667
|
+
className={`block py-1.5 px-3 text-base rounded transition-colors duration-200 ease-out ${
|
|
668
|
+
isComplete
|
|
669
|
+
? 'bg-zinc-100 dark:bg-zinc-800/50'
|
|
670
|
+
: 'hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
|
671
|
+
}`}
|
|
672
|
+
>
|
|
673
|
+
<div className="flex items-center gap-3">
|
|
674
|
+
<span className={`font-mono ${isComplete ? 'text-zinc-500' : 'text-zinc-400'}`}>#{chore.id}</span>
|
|
675
|
+
{!isDone && chore.mode && MODE_LABELS[chore.mode] && (
|
|
676
|
+
<span className={`px-1 py-0.5 rounded text-[10px] ${MODE_LABELS[chore.mode].color}`}>
|
|
677
|
+
{getModeLabel(chore)}
|
|
678
|
+
</span>
|
|
679
|
+
)}
|
|
680
|
+
<span className={`truncate ${
|
|
681
|
+
isComplete
|
|
682
|
+
? 'text-zinc-500'
|
|
683
|
+
: 'text-zinc-700 dark:text-zinc-300'
|
|
684
|
+
}`}>
|
|
685
|
+
{chore.title || <span className="text-zinc-400 italic">(Untitled)</span>}
|
|
686
|
+
</span>
|
|
687
|
+
</div>
|
|
688
|
+
</Link>
|
|
689
|
+
);
|
|
690
|
+
})}
|
|
691
|
+
{allBugs.map((bug) => {
|
|
692
|
+
const isComplete = bug.status === 'done';
|
|
693
|
+
return (
|
|
694
|
+
<Link
|
|
695
|
+
key={bug.id}
|
|
696
|
+
to={`/work/${bug.id}`}
|
|
697
|
+
viewTransition
|
|
698
|
+
className={`block py-1.5 px-3 text-base rounded transition-colors duration-200 ease-out ${
|
|
699
|
+
isComplete
|
|
700
|
+
? 'bg-zinc-100 dark:bg-zinc-800/50'
|
|
701
|
+
: 'hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
|
702
|
+
}`}
|
|
703
|
+
>
|
|
704
|
+
<div className="flex items-center gap-3">
|
|
705
|
+
<span className={`font-mono ${isComplete ? 'text-zinc-500' : 'text-zinc-400'}`}>#{bug.id}</span>
|
|
706
|
+
<TypeIcon type="bug" />
|
|
707
|
+
<span className={`truncate ${
|
|
708
|
+
isComplete
|
|
709
|
+
? 'text-zinc-500'
|
|
710
|
+
: 'text-zinc-700 dark:text-zinc-300'
|
|
711
|
+
}`}>
|
|
712
|
+
{bug.title || <span className="text-zinc-400 italic">(Untitled)</span>}
|
|
713
|
+
</span>
|
|
714
|
+
</div>
|
|
715
|
+
</Link>
|
|
716
|
+
);
|
|
717
|
+
})}
|
|
718
|
+
</>
|
|
719
|
+
)}
|
|
475
720
|
</div>
|
|
476
721
|
)}
|
|
477
722
|
</div>
|
|
@@ -482,23 +727,12 @@ export const KanbanCard = memo(function KanbanCard({ item, epicTitle, showEpic =
|
|
|
482
727
|
|
|
483
728
|
if (isHighlighted) {
|
|
484
729
|
return (
|
|
485
|
-
<
|
|
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
|
-
}}
|
|
730
|
+
<div
|
|
498
731
|
className="rounded-xl"
|
|
732
|
+
style={{ animation: 'highlight-pulse 2s ease-in-out infinite' }}
|
|
499
733
|
>
|
|
500
734
|
{cardContent}
|
|
501
|
-
</
|
|
735
|
+
</div>
|
|
502
736
|
);
|
|
503
737
|
}
|
|
504
738
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useRef, useState, useEffect, memo } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Finds the nearest scrollable ancestor for IntersectionObserver root.
|
|
5
|
+
*/
|
|
6
|
+
function getScrollParent(el: HTMLElement): HTMLElement | null {
|
|
7
|
+
let parent = el.parentElement;
|
|
8
|
+
while (parent) {
|
|
9
|
+
const { overflowY } = getComputedStyle(parent);
|
|
10
|
+
if (overflowY === 'auto' || overflowY === 'scroll') return parent;
|
|
11
|
+
parent = parent.parentElement;
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface LazyCardProps {
|
|
17
|
+
children: React.ReactNode;
|
|
18
|
+
/** Estimated card height used before first measurement */
|
|
19
|
+
estimatedHeight?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Renders children only when visible in the scroll container.
|
|
24
|
+
* Offscreen cards are replaced with a height-matched placeholder,
|
|
25
|
+
* dramatically reducing DOM node count for large lists.
|
|
26
|
+
*/
|
|
27
|
+
export const LazyCard = memo(function LazyCard({
|
|
28
|
+
children,
|
|
29
|
+
estimatedHeight = 82,
|
|
30
|
+
}: LazyCardProps) {
|
|
31
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
32
|
+
const [visible, setVisible] = useState(false);
|
|
33
|
+
const measuredHeight = useRef(estimatedHeight);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const el = ref.current;
|
|
37
|
+
if (!el) return;
|
|
38
|
+
|
|
39
|
+
const root = getScrollParent(el);
|
|
40
|
+
const io = new IntersectionObserver(
|
|
41
|
+
([entry]) => {
|
|
42
|
+
if (entry.isIntersecting) {
|
|
43
|
+
setVisible(true);
|
|
44
|
+
} else {
|
|
45
|
+
// Capture height before replacing children with placeholder
|
|
46
|
+
const h = el.offsetHeight;
|
|
47
|
+
if (h > 0) measuredHeight.current = h;
|
|
48
|
+
setVisible(false);
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
{ root, rootMargin: '300px 0px' }
|
|
52
|
+
);
|
|
53
|
+
io.observe(el);
|
|
54
|
+
return () => io.disconnect();
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div ref={ref} style={visible ? undefined : { height: measuredHeight.current }}>
|
|
59
|
+
{visible ? children : null}
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
});
|