jettypod 4.4.116 → 4.4.120
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env +7 -0
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +124 -48
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +171 -58
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +161 -10
- package/apps/dashboard/app/api/tests/run/stream/route.ts +13 -1
- package/apps/dashboard/app/api/usage/route.ts +17 -0
- package/apps/dashboard/app/api/work/[id]/route.ts +35 -0
- package/apps/dashboard/app/api/work/[id]/status/route.ts +43 -1
- package/apps/dashboard/app/connect-claude/page.tsx +24 -0
- package/apps/dashboard/app/decision/[id]/page.tsx +14 -14
- package/apps/dashboard/app/demo/gates/page.tsx +42 -42
- package/apps/dashboard/app/design-system/page.tsx +868 -0
- package/apps/dashboard/app/globals.css +6 -2
- package/apps/dashboard/app/install-claude/page.tsx +9 -7
- package/apps/dashboard/app/layout.tsx +17 -5
- package/apps/dashboard/app/login/page.tsx +250 -0
- package/apps/dashboard/app/page.tsx +11 -9
- package/apps/dashboard/app/settings/page.tsx +4 -2
- package/apps/dashboard/app/signup/page.tsx +245 -0
- package/apps/dashboard/app/subscribe/page.tsx +11 -0
- package/apps/dashboard/app/welcome/page.tsx +24 -1
- package/apps/dashboard/app/work/[id]/page.tsx +34 -50
- package/apps/dashboard/components/AppShell.tsx +95 -55
- package/apps/dashboard/components/CardMenu.tsx +56 -13
- package/apps/dashboard/components/ClaudePanel.tsx +301 -582
- package/apps/dashboard/components/ClaudePanelInput.tsx +23 -14
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +210 -0
- package/apps/dashboard/components/CopyableId.tsx +3 -3
- package/apps/dashboard/components/DetailReviewActions.tsx +109 -0
- package/apps/dashboard/components/DragContext.tsx +75 -65
- package/apps/dashboard/components/DraggableCard.tsx +6 -46
- package/apps/dashboard/components/DropZone.tsx +2 -2
- package/apps/dashboard/components/EditableDetailDescription.tsx +1 -1
- package/apps/dashboard/components/EditableTitle.tsx +26 -6
- package/apps/dashboard/components/ElapsedTimer.tsx +54 -0
- package/apps/dashboard/components/EpicGroup.tsx +329 -0
- package/apps/dashboard/components/GateCard.tsx +100 -16
- package/apps/dashboard/components/GateChoiceCard.tsx +15 -17
- package/apps/dashboard/components/InstallClaudeScreen.tsx +140 -51
- package/apps/dashboard/components/JettyLoader.tsx +38 -0
- package/apps/dashboard/components/KanbanBoard.tsx +147 -766
- package/apps/dashboard/components/KanbanCard.tsx +506 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +12 -0
- package/apps/dashboard/components/MainNav.tsx +20 -54
- package/apps/dashboard/components/MessageBlock.tsx +391 -0
- package/apps/dashboard/components/ModeStartCard.tsx +15 -15
- package/apps/dashboard/components/OnboardingWelcome.tsx +214 -0
- package/apps/dashboard/components/PlaceholderCard.tsx +11 -21
- package/apps/dashboard/components/ProjectSwitcher.tsx +36 -8
- package/apps/dashboard/components/PrototypeTimeline.tsx +25 -25
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +265 -301
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +97 -74
- package/apps/dashboard/components/ReviewFooter.tsx +141 -0
- package/apps/dashboard/components/SessionList.tsx +19 -18
- package/apps/dashboard/components/SubscribeContent.tsx +206 -0
- package/apps/dashboard/components/TestTree.tsx +15 -14
- package/apps/dashboard/components/TipCard.tsx +177 -0
- package/apps/dashboard/components/Toast.tsx +5 -5
- package/apps/dashboard/components/TypeIcon.tsx +56 -0
- package/apps/dashboard/components/UpgradeBanner.tsx +30 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +61 -62
- package/apps/dashboard/components/WelcomeScreen.tsx +25 -27
- package/apps/dashboard/components/WorkItemHeader.tsx +4 -4
- package/apps/dashboard/components/WorkItemTree.tsx +9 -28
- package/apps/dashboard/components/settings/AccountSection.tsx +169 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +54 -79
- package/apps/dashboard/components/settings/GeneralSection.tsx +26 -31
- package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -4
- package/apps/dashboard/components/ui/Button.tsx +104 -0
- package/apps/dashboard/components/ui/Input.tsx +78 -0
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +408 -105
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -4
- package/apps/dashboard/contexts/UsageContext.tsx +155 -0
- package/apps/dashboard/contexts/usageHelpers.js +9 -0
- package/apps/dashboard/electron/ipc-handlers.js +281 -88
- package/apps/dashboard/electron/main.js +691 -131
- package/apps/dashboard/electron/preload.js +25 -4
- package/apps/dashboard/electron/session-manager.js +163 -0
- package/apps/dashboard/electron-builder.config.js +3 -5
- package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
- package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
- package/apps/dashboard/lib/backlog-parser.ts +50 -0
- package/apps/dashboard/lib/claude-process-manager.ts +50 -11
- package/apps/dashboard/lib/constants.ts +43 -0
- package/apps/dashboard/lib/db-bridge.ts +33 -0
- package/apps/dashboard/lib/db.ts +136 -20
- package/apps/dashboard/lib/kanban-utils.ts +70 -0
- package/apps/dashboard/lib/run-migrations.js +27 -2
- package/apps/dashboard/lib/session-state-machine.ts +3 -0
- package/apps/dashboard/lib/session-stream-manager.ts +144 -38
- package/apps/dashboard/lib/shadows.ts +7 -0
- package/apps/dashboard/lib/tests.ts +3 -1
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next.config.js +35 -14
- package/apps/dashboard/package.json +6 -3
- package/apps/dashboard/public/bug-icon.svg +9 -0
- package/apps/dashboard/public/buoy-icon.svg +9 -0
- package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
- package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
- package/apps/dashboard/public/in-flight-seagull.svg +9 -0
- package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
- package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
- package/apps/dashboard/public/jettypod_logo.png +0 -0
- package/apps/dashboard/public/pier-icon.svg +14 -0
- package/apps/dashboard/public/star-icon.svg +9 -0
- package/apps/dashboard/public/wrench-icon.svg +9 -0
- package/apps/dashboard/scripts/upload-to-r2.js +89 -0
- package/apps/dashboard/scripts/ws-server.js +191 -0
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
- package/apps/update-server/package.json +16 -0
- package/apps/update-server/schema.sql +31 -0
- package/apps/update-server/src/index.ts +1085 -0
- package/apps/update-server/tsconfig.json +16 -0
- package/apps/update-server/wrangler.toml +35 -0
- package/cucumber.js +9 -3
- package/docs/COMMAND_REFERENCE.md +34 -0
- package/hooks/post-checkout +32 -75
- package/hooks/post-merge +111 -10
- package/jest.setup.js +1 -0
- package/jettypod.js +54 -116
- package/lib/chore-taxonomy.js +33 -10
- package/lib/database.js +36 -16
- package/lib/db-watcher.js +1 -1
- package/lib/git-hooks/pre-commit +1 -1
- package/lib/jettypod-backup.js +27 -4
- package/lib/migrations/027-plan-at-creation-column.js +33 -0
- package/lib/migrations/028-ready-for-review-column.js +27 -0
- package/lib/migrations/029-remove-autoincrement.js +307 -0
- package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
- package/lib/migrations/index.js +47 -4
- package/lib/schema.js +13 -6
- package/lib/seed-onboarding.js +101 -69
- package/lib/update-command/index.js +9 -175
- package/lib/work-commands/index.js +129 -16
- package/lib/work-tracking/index.js +86 -46
- package/lib/worktree-diagnostics.js +16 -16
- package/lib/worktree-facade.js +1 -1
- package/lib/worktree-manager.js +8 -8
- package/lib/worktree-reconciler.js +5 -5
- package/package.json +9 -2
- package/scripts/ndjson-to-cucumber-json.js +152 -0
- package/scripts/postinstall.js +25 -0
- package/skills-templates/bug-mode/SKILL.md +39 -28
- package/skills-templates/bug-planning/SKILL.md +25 -29
- package/skills-templates/chore-mode/SKILL.md +131 -68
- package/skills-templates/chore-mode/verification.js +51 -10
- package/skills-templates/chore-planning/SKILL.md +47 -18
- package/skills-templates/epic-planning/SKILL.md +68 -48
- package/skills-templates/external-transition/SKILL.md +47 -47
- package/skills-templates/feature-planning/SKILL.md +83 -73
- package/skills-templates/production-mode/SKILL.md +49 -49
- package/skills-templates/request-routing/SKILL.md +27 -14
- package/skills-templates/simple-improvement/SKILL.md +68 -44
- package/skills-templates/speed-mode/SKILL.md +209 -128
- package/skills-templates/stable-mode/SKILL.md +105 -94
- package/templates/bdd-guidance.md +139 -0
- package/templates/bdd-scaffolding/wait.js +18 -0
- package/templates/bdd-scaffolding/world.js +19 -0
- package/.jettypod-backup/work.db +0 -0
- package/apps/dashboard/app/access-code/page.tsx +0 -110
- package/lib/discovery-checkpoint.js +0 -123
- package/skills-templates/project-discovery/SKILL.md +0 -372
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { AnimatePresence } from 'framer-motion';
|
|
6
|
+
import { useDroppable } from '@dnd-kit/core';
|
|
7
|
+
import type { WorkItem, InFlightItem } from '@/lib/db';
|
|
8
|
+
import type { Session } from '../contexts/ClaudeSessionContext';
|
|
9
|
+
import { KanbanCard } from './KanbanCard';
|
|
10
|
+
import { useDragContext } from './DragContext';
|
|
11
|
+
import { DraggableCard } from './DraggableCard';
|
|
12
|
+
import { PlaceholderCard } from './PlaceholderCard';
|
|
13
|
+
import { TypeIcon } from './TypeIcon';
|
|
14
|
+
|
|
15
|
+
// Safe bounds for display_order to prevent overflow
|
|
16
|
+
export const MIN_DISPLAY_ORDER = 0;
|
|
17
|
+
export const MAX_DISPLAY_ORDER = Number.MAX_SAFE_INTEGER - 1000;
|
|
18
|
+
export const DISPLAY_ORDER_INCREMENT = 10;
|
|
19
|
+
|
|
20
|
+
export interface EpicGroupProps {
|
|
21
|
+
epicId: number | null;
|
|
22
|
+
epicTitle: string | null;
|
|
23
|
+
items: WorkItem[];
|
|
24
|
+
isInFlight?: boolean;
|
|
25
|
+
inFlightItems?: InFlightItem[];
|
|
26
|
+
isDraggable?: boolean;
|
|
27
|
+
onTitleSave?: (id: number, newTitle: string) => Promise<void>;
|
|
28
|
+
onStatusChange?: (id: number, newStatus: string) => Promise<void | { success: boolean; notFound?: boolean }>;
|
|
29
|
+
onReject?: (id: number, reason: string) => Promise<void>;
|
|
30
|
+
onRestart?: (id: number) => void;
|
|
31
|
+
onEpicAssign?: (id: number, epicId: number | null) => Promise<void>;
|
|
32
|
+
onOrderChange?: (id: number, newOrder: number) => Promise<void>;
|
|
33
|
+
onTriggerClaude?: (id: number, title: string, type: string, conversational?: boolean, description?: string | null) => void;
|
|
34
|
+
activeSessions?: Map<string, Session>;
|
|
35
|
+
onOpenSession?: (id: string) => void;
|
|
36
|
+
onCloseSession?: (id: string) => void;
|
|
37
|
+
onError?: (message: string) => void;
|
|
38
|
+
usageAllowed?: boolean;
|
|
39
|
+
// Animation state lifted to board level
|
|
40
|
+
animatingItemId?: number | null;
|
|
41
|
+
onAnimationComplete?: () => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function EpicGroup({ epicId, epicTitle, items, isInFlight = false, inFlightItems, isDraggable = true, onTitleSave, onStatusChange, onReject, onRestart, onEpicAssign, onOrderChange, onTriggerClaude, activeSessions, onOpenSession, onCloseSession, onError, usageAllowed = true, animatingItemId, onAnimationComplete }: EpicGroupProps) {
|
|
45
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
46
|
+
const { isDragging, draggedItem, activeEpicZone, activeDropZone, registerEpicDropZone, unregisterEpicDropZone, getCardPositions } = useDragContext();
|
|
47
|
+
|
|
48
|
+
// Local pointer tracking - only this component needs pointer Y for insertion preview.
|
|
49
|
+
// Using local state avoids re-rendering every context consumer at 60fps.
|
|
50
|
+
const [pointerY, setPointerY] = useState(0);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!isDragging) return;
|
|
53
|
+
const onPointerMove = (e: PointerEvent) => { setPointerY(e.clientY); };
|
|
54
|
+
window.addEventListener('pointermove', onPointerMove);
|
|
55
|
+
return () => window.removeEventListener('pointermove', onPointerMove);
|
|
56
|
+
}, [isDragging]);
|
|
57
|
+
|
|
58
|
+
// Use @dnd-kit's useDroppable for epic zone collision detection
|
|
59
|
+
const zoneId = epicId !== null ? `epic-${epicId}` : undefined;
|
|
60
|
+
const { setNodeRef } = useDroppable({
|
|
61
|
+
id: zoneId || 'ungrouped',
|
|
62
|
+
disabled: epicId === null, // Don't use droppable for ungrouped section
|
|
63
|
+
data: { epicId },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Combine refs
|
|
67
|
+
const setRefs = useCallback((node: HTMLDivElement | null) => {
|
|
68
|
+
if (epicId !== null) {
|
|
69
|
+
setNodeRef(node);
|
|
70
|
+
}
|
|
71
|
+
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
|
72
|
+
}, [epicId, setNodeRef]);
|
|
73
|
+
|
|
74
|
+
// Use ref for items to avoid re-registering drop zone when items change
|
|
75
|
+
const itemsRef = useRef(items);
|
|
76
|
+
itemsRef.current = items;
|
|
77
|
+
|
|
78
|
+
// Use ref for callbacks to keep drop zone registration stable
|
|
79
|
+
const onOrderChangeRef = useRef(onOrderChange);
|
|
80
|
+
onOrderChangeRef.current = onOrderChange;
|
|
81
|
+
|
|
82
|
+
// Use ref for error handler to keep reorder handler stable
|
|
83
|
+
const onErrorRef = useRef(onError);
|
|
84
|
+
onErrorRef.current = onError;
|
|
85
|
+
|
|
86
|
+
// Stable reorder handler that reads from refs
|
|
87
|
+
const handleEpicReorder = useCallback(async (itemId: number, pointerY: number) => {
|
|
88
|
+
if (!onOrderChangeRef.current) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const currentItems = itemsRef.current.filter(item => item.id !== itemId);
|
|
93
|
+
if (currentItems.length === 0) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Read fresh positions from DOM (not stale cache)
|
|
98
|
+
const allPositions = getCardPositions();
|
|
99
|
+
const itemIds = new Set(currentItems.map(item => item.id));
|
|
100
|
+
const cardPositions = allPositions
|
|
101
|
+
.filter(pos => itemIds.has(pos.id))
|
|
102
|
+
.map(pos => ({
|
|
103
|
+
id: pos.id,
|
|
104
|
+
midY: (pos.rect.top + pos.rect.bottom) / 2,
|
|
105
|
+
}))
|
|
106
|
+
.sort((a, b) => a.midY - b.midY);
|
|
107
|
+
|
|
108
|
+
if (cardPositions.length === 0) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Find insertion index based on pointer Y
|
|
113
|
+
let insertIndex = cardPositions.length;
|
|
114
|
+
for (let i = 0; i < cardPositions.length; i++) {
|
|
115
|
+
if (pointerY < cardPositions[i].midY) {
|
|
116
|
+
insertIndex = i;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Map visual positions to items for display_order midpoint calculation
|
|
122
|
+
const itemMap = new Map(currentItems.map(item => [item.id, item]));
|
|
123
|
+
const visualOrder = cardPositions.map(pos => itemMap.get(pos.id)!).filter(Boolean);
|
|
124
|
+
|
|
125
|
+
// Calculate proper midpoint display_order between surrounding items
|
|
126
|
+
let newOrder: number;
|
|
127
|
+
if (visualOrder.length === 0) {
|
|
128
|
+
newOrder = DISPLAY_ORDER_INCREMENT;
|
|
129
|
+
} else if (insertIndex === 0) {
|
|
130
|
+
const firstOrder = visualOrder[0].display_order ?? visualOrder[0].id;
|
|
131
|
+
newOrder = firstOrder - DISPLAY_ORDER_INCREMENT;
|
|
132
|
+
} else if (insertIndex >= visualOrder.length) {
|
|
133
|
+
const lastOrder = visualOrder[visualOrder.length - 1].display_order ?? visualOrder[visualOrder.length - 1].id;
|
|
134
|
+
newOrder = lastOrder + DISPLAY_ORDER_INCREMENT;
|
|
135
|
+
} else {
|
|
136
|
+
const before = visualOrder[insertIndex - 1].display_order ?? visualOrder[insertIndex - 1].id;
|
|
137
|
+
const after = visualOrder[insertIndex].display_order ?? visualOrder[insertIndex].id;
|
|
138
|
+
newOrder = Math.floor((before + after) / 2);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
newOrder = Math.max(MIN_DISPLAY_ORDER, Math.min(MAX_DISPLAY_ORDER, newOrder));
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
await onOrderChangeRef.current(itemId, newOrder);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
const errorMessage = error instanceof Error ? error.message : 'Failed to reorder item. Please try again.';
|
|
147
|
+
onErrorRef.current?.(errorMessage);
|
|
148
|
+
}
|
|
149
|
+
}, [getCardPositions]);
|
|
150
|
+
|
|
151
|
+
// Register as epic drop zone - stable registration that doesn't change with items
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
if (!containerRef.current || !onEpicAssign || epicId === null) return;
|
|
154
|
+
|
|
155
|
+
const zoneId = `epic-${epicId}`;
|
|
156
|
+
registerEpicDropZone(zoneId, {
|
|
157
|
+
epicId,
|
|
158
|
+
element: containerRef.current,
|
|
159
|
+
onEpicAssign,
|
|
160
|
+
onReorder: handleEpicReorder,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
return () => {
|
|
164
|
+
unregisterEpicDropZone(zoneId);
|
|
165
|
+
};
|
|
166
|
+
}, [epicId, onEpicAssign, handleEpicReorder, registerEpicDropZone, unregisterEpicDropZone]);
|
|
167
|
+
|
|
168
|
+
// Check if this epic zone is the active drop target
|
|
169
|
+
const isActiveTarget = activeEpicZone === `epic-${epicId}`;
|
|
170
|
+
|
|
171
|
+
// Check if the dragged item is from a different epic or same epic
|
|
172
|
+
const draggedItemEpicId = draggedItem ? (draggedItem.parent_id || draggedItem.epic_id) : null;
|
|
173
|
+
const isDifferentEpic = isDragging && draggedItem && draggedItemEpicId !== epicId;
|
|
174
|
+
const isSameEpic = isDragging && draggedItem && draggedItemEpicId === epicId;
|
|
175
|
+
|
|
176
|
+
// Show highlight when dragging an item from different epic over this group (indigo)
|
|
177
|
+
const showHighlight = isActiveTarget && isDifferentEpic;
|
|
178
|
+
// Show reorder highlight when dragging within same epic (purple)
|
|
179
|
+
const showReorderHighlight = isActiveTarget && isSameEpic;
|
|
180
|
+
|
|
181
|
+
// For ungrouped section (epicId === null)
|
|
182
|
+
const isUngroupedSection = epicId === null;
|
|
183
|
+
// Check if cursor is over this ungrouped section (not over any epic zone, but over backlog drop zone)
|
|
184
|
+
const isOverUngroupedSection = isUngroupedSection && !activeEpicZone && activeDropZone;
|
|
185
|
+
|
|
186
|
+
// Render the ungrouped zone when dragging from an epic (provides drop target), but only highlight when cursor is over it
|
|
187
|
+
const shouldRenderUngroupedZone = isUngroupedSection && isDragging && draggedItemEpicId !== null;
|
|
188
|
+
const showRemoveFromEpicZone = isOverUngroupedSection && isDragging && draggedItemEpicId !== null;
|
|
189
|
+
|
|
190
|
+
// Show reorder for ungrouped section when dragging an ungrouped card and cursor is over it
|
|
191
|
+
const showUngroupedReorder = isOverUngroupedSection && isDragging && draggedItemEpicId === null;
|
|
192
|
+
|
|
193
|
+
// Calculate insertion preview for this group - only for the active zone
|
|
194
|
+
const showPreview = (showReorderHighlight || showRemoveFromEpicZone || showHighlight || showUngroupedReorder) && draggedItem;
|
|
195
|
+
let insertAfterItemId: number | null | undefined = undefined; // undefined = no preview, null = at beginning
|
|
196
|
+
|
|
197
|
+
if (showPreview && draggedItem) {
|
|
198
|
+
const allPositions = getCardPositions();
|
|
199
|
+
const itemIds = new Set(items.map(item => item.id));
|
|
200
|
+
const groupPositions = allPositions
|
|
201
|
+
.filter(pos => itemIds.has(pos.id) && pos.id !== draggedItem.id)
|
|
202
|
+
.map(pos => ({
|
|
203
|
+
id: pos.id,
|
|
204
|
+
midY: (pos.rect.top + pos.rect.bottom) / 2,
|
|
205
|
+
}))
|
|
206
|
+
.sort((a, b) => a.midY - b.midY);
|
|
207
|
+
|
|
208
|
+
// Find which card the pointer is after
|
|
209
|
+
insertAfterItemId = null; // Default to beginning
|
|
210
|
+
for (const pos of groupPositions) {
|
|
211
|
+
if (pointerY > pos.midY) {
|
|
212
|
+
insertAfterItemId = pos.id;
|
|
213
|
+
} else {
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (items.length === 0 && !showHighlight && !showReorderHighlight && !shouldRenderUngroupedZone) return null;
|
|
220
|
+
|
|
221
|
+
// Standalone done items (single item, no epic) use tighter spacing
|
|
222
|
+
const isStandaloneItem = !epicTitle && items.length === 1;
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<div
|
|
226
|
+
ref={setRefs}
|
|
227
|
+
className={`${isStandaloneItem ? 'mb-2' : 'mb-6 p-3 -mx-3'} rounded-lg transition-[color,background-color,box-shadow] duration-200 ease-out ${
|
|
228
|
+
showHighlight
|
|
229
|
+
? 'ring-2 ring-indigo-400 bg-indigo-100/50 dark:bg-indigo-900/30'
|
|
230
|
+
: showReorderHighlight
|
|
231
|
+
? 'ring-2 ring-purple-400 bg-purple-100/50 dark:bg-purple-900/30'
|
|
232
|
+
: showRemoveFromEpicZone
|
|
233
|
+
? 'ring-2 ring-orange-400 bg-orange-100/50 dark:bg-orange-900/30'
|
|
234
|
+
: ''
|
|
235
|
+
}`}
|
|
236
|
+
data-epic-id={epicId}
|
|
237
|
+
>
|
|
238
|
+
{epicTitle && (
|
|
239
|
+
<div className="flex items-center gap-3 mb-3">
|
|
240
|
+
<Link
|
|
241
|
+
href={`/work/${epicId}`}
|
|
242
|
+
className="group/epic flex items-center gap-1.5 text-base font-medium text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300"
|
|
243
|
+
>
|
|
244
|
+
<TypeIcon type="epic" />
|
|
245
|
+
<span className="group-hover/epic:underline">{epicTitle}</span>
|
|
246
|
+
</Link>
|
|
247
|
+
{isInFlight && (
|
|
248
|
+
<span
|
|
249
|
+
className="relative group/inflight text-xs px-2 py-1 rounded bg-[#e8f0f0] text-[#5a7d7f] dark:bg-[#819D9F]/20 dark:text-[#a3bfc0] cursor-default"
|
|
250
|
+
>
|
|
251
|
+
in flight
|
|
252
|
+
{inFlightItems && inFlightItems.length > 0 && (
|
|
253
|
+
<span className="pointer-events-none absolute left-1/2 -translate-x-1/2 top-full mt-2 z-50 hidden group-hover/inflight:block w-max max-w-xs">
|
|
254
|
+
<span className="block rounded-lg bg-zinc-800 dark:bg-zinc-700 text-zinc-100 text-xs px-3 py-2 shadow-lg">
|
|
255
|
+
{inFlightItems.map(item => (
|
|
256
|
+
<span key={item.id} className="flex items-center gap-1 py-0.5 truncate">
|
|
257
|
+
<TypeIcon type={item.type} /> {item.title}
|
|
258
|
+
</span>
|
|
259
|
+
))}
|
|
260
|
+
</span>
|
|
261
|
+
</span>
|
|
262
|
+
)}
|
|
263
|
+
</span>
|
|
264
|
+
)}
|
|
265
|
+
{showHighlight && (
|
|
266
|
+
<span className="text-xs px-2 py-1 rounded bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-300">
|
|
267
|
+
drop to assign
|
|
268
|
+
</span>
|
|
269
|
+
)}
|
|
270
|
+
{showReorderHighlight && (
|
|
271
|
+
<span className="text-xs px-2 py-1 rounded bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300">
|
|
272
|
+
reorder
|
|
273
|
+
</span>
|
|
274
|
+
)}
|
|
275
|
+
</div>
|
|
276
|
+
)}
|
|
277
|
+
{/* Ungrouped section header - shown when dragging from epic */}
|
|
278
|
+
{isUngroupedSection && showRemoveFromEpicZone && items.length === 0 && (
|
|
279
|
+
<div className="flex items-center gap-3 py-4">
|
|
280
|
+
<span className="text-base font-medium text-orange-600 dark:text-orange-400">
|
|
281
|
+
Drop here to remove from epic
|
|
282
|
+
</span>
|
|
283
|
+
</div>
|
|
284
|
+
)}
|
|
285
|
+
{isUngroupedSection && items.length > 0 && isDraggable && showRemoveFromEpicZone && (
|
|
286
|
+
<div className="flex items-center gap-3 mb-3">
|
|
287
|
+
<span className="text-xs px-2 py-1 rounded bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300">
|
|
288
|
+
drop to remove from epic
|
|
289
|
+
</span>
|
|
290
|
+
</div>
|
|
291
|
+
)}
|
|
292
|
+
<div className="space-y-3">
|
|
293
|
+
{/* Placeholder at the beginning (insertAfterItemId === null) */}
|
|
294
|
+
<AnimatePresence>
|
|
295
|
+
{insertAfterItemId === null && (
|
|
296
|
+
<PlaceholderCard key="placeholder-start" />
|
|
297
|
+
)}
|
|
298
|
+
</AnimatePresence>
|
|
299
|
+
{items.map((item) => (
|
|
300
|
+
<div key={item.id}>
|
|
301
|
+
<DraggableCard item={item} disabled={!isDraggable}>
|
|
302
|
+
<KanbanCard
|
|
303
|
+
item={item}
|
|
304
|
+
onTitleSave={onTitleSave}
|
|
305
|
+
onStatusChange={onStatusChange}
|
|
306
|
+
onReject={onReject}
|
|
307
|
+
onRestart={onRestart}
|
|
308
|
+
onTriggerClaude={onTriggerClaude}
|
|
309
|
+
hasActiveSession={activeSessions?.has(String(item.id))}
|
|
310
|
+
onOpenSession={onOpenSession}
|
|
311
|
+
onCloseSession={onCloseSession}
|
|
312
|
+
usageAllowed={usageAllowed}
|
|
313
|
+
isCompletingAnimation={animatingItemId === item.id}
|
|
314
|
+
onAnimationComplete={onAnimationComplete}
|
|
315
|
+
isHighlighted={false}
|
|
316
|
+
/>
|
|
317
|
+
</DraggableCard>
|
|
318
|
+
{/* Placeholder after this card */}
|
|
319
|
+
<AnimatePresence>
|
|
320
|
+
{insertAfterItemId === item.id && (
|
|
321
|
+
<PlaceholderCard key={`placeholder-${item.id}`} />
|
|
322
|
+
)}
|
|
323
|
+
</AnimatePresence>
|
|
324
|
+
</div>
|
|
325
|
+
))}
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
);
|
|
329
|
+
}
|
|
@@ -5,6 +5,8 @@ import type { ClaudeMessage } from '../lib/session-stream-manager';
|
|
|
5
5
|
import { GateChoiceCard } from './GateChoiceCard';
|
|
6
6
|
import type { ChoiceOption } from './GateChoiceCard';
|
|
7
7
|
import { ModeStartCard, isModeStartGate } from './ModeStartCard';
|
|
8
|
+
import { TipCard } from './TipCard';
|
|
9
|
+
import { TypeIcon } from './TypeIcon';
|
|
8
10
|
|
|
9
11
|
// Gate display configuration matching JettyPod design system
|
|
10
12
|
const GATE_CONFIG: Record<string, {
|
|
@@ -89,11 +91,11 @@ const GATE_CONFIG: Record<string, {
|
|
|
89
91
|
label: 'Saving Changes',
|
|
90
92
|
bg: 'bg-white',
|
|
91
93
|
darkBg: 'dark:bg-zinc-800',
|
|
92
|
-
border: 'border-
|
|
93
|
-
darkBorder: 'dark:border-
|
|
94
|
+
border: 'border-[#819D9F]/30',
|
|
95
|
+
darkBorder: 'dark:border-[#819D9F]/30',
|
|
94
96
|
text: 'text-zinc-700',
|
|
95
97
|
darkText: 'dark:text-zinc-300',
|
|
96
|
-
fileMono: 'text-
|
|
98
|
+
fileMono: 'text-[#5a7d7f]',
|
|
97
99
|
},
|
|
98
100
|
'complete': {
|
|
99
101
|
emoji: '✨',
|
|
@@ -117,6 +119,39 @@ const GATE_CONFIG: Record<string, {
|
|
|
117
119
|
darkText: 'dark:text-zinc-300',
|
|
118
120
|
fileMono: 'text-indigo-600',
|
|
119
121
|
},
|
|
122
|
+
'work-item-card': {
|
|
123
|
+
emoji: '📋',
|
|
124
|
+
label: 'Added to Backlog',
|
|
125
|
+
bg: 'bg-white',
|
|
126
|
+
darkBg: 'dark:bg-zinc-800',
|
|
127
|
+
border: 'border-emerald-200',
|
|
128
|
+
darkBorder: 'dark:border-emerald-800',
|
|
129
|
+
text: 'text-zinc-700',
|
|
130
|
+
darkText: 'dark:text-zinc-300',
|
|
131
|
+
fileMono: 'text-emerald-600',
|
|
132
|
+
},
|
|
133
|
+
'tip': {
|
|
134
|
+
emoji: '💡',
|
|
135
|
+
label: 'Tip',
|
|
136
|
+
bg: 'bg-teal-50',
|
|
137
|
+
darkBg: 'dark:bg-teal-900/20',
|
|
138
|
+
border: 'border-teal-200',
|
|
139
|
+
darkBorder: 'dark:border-teal-800',
|
|
140
|
+
text: 'text-zinc-700',
|
|
141
|
+
darkText: 'dark:text-zinc-300',
|
|
142
|
+
fileMono: 'text-teal-600',
|
|
143
|
+
},
|
|
144
|
+
'rejection': {
|
|
145
|
+
emoji: '❌',
|
|
146
|
+
label: 'Work Rejected',
|
|
147
|
+
bg: 'bg-red-50',
|
|
148
|
+
darkBg: 'dark:bg-red-900/20',
|
|
149
|
+
border: 'border-red-200',
|
|
150
|
+
darkBorder: 'dark:border-red-800',
|
|
151
|
+
text: 'text-zinc-700',
|
|
152
|
+
darkText: 'dark:text-zinc-300',
|
|
153
|
+
fileMono: 'text-red-600',
|
|
154
|
+
},
|
|
120
155
|
};
|
|
121
156
|
|
|
122
157
|
const DEFAULT_CONFIG = {
|
|
@@ -131,9 +166,7 @@ const DEFAULT_CONFIG = {
|
|
|
131
166
|
fileMono: 'text-zinc-500',
|
|
132
167
|
};
|
|
133
168
|
|
|
134
|
-
|
|
135
|
-
const CARD_SHADOW = '0 1px 2px rgba(0,0,0,0.03), 0 2px 4px rgba(0,0,0,0.03), 0 4px 8px rgba(0,0,0,0.02)';
|
|
136
|
-
const CARD_SHADOW_ACTIVE = '0 2px 4px rgba(0,0,0,0.04), 0 4px 8px rgba(0,0,0,0.04), 0 8px 16px rgba(0,0,0,0.03)';
|
|
169
|
+
import { shadow } from '@/lib/shadows';
|
|
137
170
|
|
|
138
171
|
/**
|
|
139
172
|
* Render a human-friendly description based on gate type and data
|
|
@@ -151,7 +184,8 @@ function getGateDescription(gateType: string, data: Record<string, unknown>): st
|
|
|
151
184
|
};
|
|
152
185
|
return routeLabels[route || ''] || `Routing to ${route || 'workflow'}`;
|
|
153
186
|
}
|
|
154
|
-
case 'work-created':
|
|
187
|
+
case 'work-created':
|
|
188
|
+
case 'work-item-card': {
|
|
155
189
|
const title = data.title as string | undefined;
|
|
156
190
|
const id = data.id as number | undefined;
|
|
157
191
|
return title ? `#${id || '?'} ${title}` : 'Work item created';
|
|
@@ -183,6 +217,10 @@ function getGateDescription(gateType: string, data: Record<string, unknown>): st
|
|
|
183
217
|
const question = data.question as string | undefined;
|
|
184
218
|
return question || 'A decision is needed';
|
|
185
219
|
}
|
|
220
|
+
case 'rejection': {
|
|
221
|
+
const reason = data.reason as string | undefined;
|
|
222
|
+
return reason || 'Work was rejected';
|
|
223
|
+
}
|
|
186
224
|
default:
|
|
187
225
|
return (data.message as string) || gateType;
|
|
188
226
|
}
|
|
@@ -193,9 +231,10 @@ interface GateCardProps {
|
|
|
193
231
|
isLatest?: boolean;
|
|
194
232
|
onAnswerQuestion?: (optionId: string, optionLabel: string) => void;
|
|
195
233
|
answeredQuestionId?: string | null;
|
|
234
|
+
onStartWorkItem?: (id: number, title: string, type: string) => void;
|
|
196
235
|
}
|
|
197
236
|
|
|
198
|
-
export function GateCard({ message, isLatest = false, onAnswerQuestion, answeredQuestionId }: GateCardProps) {
|
|
237
|
+
export function GateCard({ message, isLatest = false, onAnswerQuestion, answeredQuestionId, onStartWorkItem }: GateCardProps) {
|
|
199
238
|
const gateType = message.gateType || 'unknown';
|
|
200
239
|
const gateData = message.gateData || {};
|
|
201
240
|
const config = GATE_CONFIG[gateType] || DEFAULT_CONFIG;
|
|
@@ -207,6 +246,51 @@ export function GateCard({ message, isLatest = false, onAnswerQuestion, answered
|
|
|
207
246
|
return <ModeStartCard gateType={gateType} />;
|
|
208
247
|
}
|
|
209
248
|
|
|
249
|
+
// Tip gates render as dismissible guidance cards
|
|
250
|
+
if (gateType === 'tip') {
|
|
251
|
+
const tipId = (gateData.id as string) || `tip-${message.timestamp}`;
|
|
252
|
+
const icon = (gateData.icon as string) || '💡';
|
|
253
|
+
const title = (gateData.title as string) || 'Tip';
|
|
254
|
+
const body = (gateData.body as string) || '';
|
|
255
|
+
return <TipCard tipId={tipId} icon={icon} title={title} body={body} />;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Work item card gates render as mini kanban cards with Start button
|
|
259
|
+
if (gateType === 'work-item-card') {
|
|
260
|
+
const itemId = gateData.id as number;
|
|
261
|
+
const itemTitle = gateData.title as string;
|
|
262
|
+
const itemType = (gateData.type as string) || 'feature';
|
|
263
|
+
return (
|
|
264
|
+
<div
|
|
265
|
+
className="bg-white dark:bg-zinc-800 border-2 border-emerald-200 dark:border-emerald-800 rounded-xl p-4 transition-shadow duration-200 ease-out"
|
|
266
|
+
style={{ boxShadow: shadow.sm }}
|
|
267
|
+
data-testid="gate-work-item-card"
|
|
268
|
+
>
|
|
269
|
+
<div className="flex items-center justify-between gap-3">
|
|
270
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
271
|
+
<span className="text-base flex-shrink-0"><TypeIcon type={itemType} /></span>
|
|
272
|
+
<div className="min-w-0">
|
|
273
|
+
<span className="text-xs font-medium text-zinc-400 dark:text-zinc-500">
|
|
274
|
+
#{itemId} · {itemType}
|
|
275
|
+
</span>
|
|
276
|
+
<p className="text-sm font-semibold text-zinc-800 dark:text-zinc-200 truncate">
|
|
277
|
+
{itemTitle}
|
|
278
|
+
</p>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
{onStartWorkItem && (
|
|
282
|
+
<button
|
|
283
|
+
onClick={() => onStartWorkItem(itemId, itemTitle, itemType)}
|
|
284
|
+
className="flex-shrink-0 px-3 py-1.5 text-xs font-medium rounded-lg bg-[#819D9F] hover:bg-[#6b8587] text-white transition-colors duration-150"
|
|
285
|
+
>
|
|
286
|
+
Start
|
|
287
|
+
</button>
|
|
288
|
+
)}
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
210
294
|
// Question gates render as interactive choice cards
|
|
211
295
|
if (gateType === 'question') {
|
|
212
296
|
const question = (gateData.question as string) || 'A decision is needed';
|
|
@@ -238,24 +322,24 @@ export function GateCard({ message, isLatest = false, onAnswerQuestion, answered
|
|
|
238
322
|
<div
|
|
239
323
|
className={`
|
|
240
324
|
${config.bg} ${config.darkBg}
|
|
241
|
-
border ${config.border} ${config.darkBorder}
|
|
242
|
-
rounded-xl p-
|
|
243
|
-
${isLatest ? 'ring-2 ring-
|
|
325
|
+
border-2 ${config.border} ${config.darkBorder}
|
|
326
|
+
rounded-xl p-4 transition-shadow duration-200 ease-out
|
|
327
|
+
${isLatest ? 'ring-2 ring-[#819D9F]/50 ring-offset-1' : ''}
|
|
244
328
|
`}
|
|
245
|
-
style={{ boxShadow: isLatest ?
|
|
329
|
+
style={{ boxShadow: isLatest ? shadow.md : shadow.sm }}
|
|
246
330
|
data-testid={`gate-card-${gateType}`}
|
|
247
331
|
>
|
|
248
|
-
<div className="flex items-start gap-
|
|
332
|
+
<div className="flex items-start gap-4">
|
|
249
333
|
<span className="text-base flex-shrink-0 mt-0.5">{config.emoji}</span>
|
|
250
334
|
<div className="flex-1 min-w-0">
|
|
251
|
-
<span className={`text-
|
|
335
|
+
<span className={`text-base font-semibold ${config.text} ${config.darkText}`}>
|
|
252
336
|
{config.label}
|
|
253
337
|
</span>
|
|
254
|
-
<p className={`text-
|
|
338
|
+
<p className={`text-base mt-1 ${config.text} ${config.darkText}`}>
|
|
255
339
|
{description}
|
|
256
340
|
</p>
|
|
257
341
|
{hasFiles && (
|
|
258
|
-
<div className="mt-
|
|
342
|
+
<div className="mt-3 space-y-1">
|
|
259
343
|
{(gateData.files as string[]).map((file, i) => (
|
|
260
344
|
<div key={i} className={`text-xs font-mono truncate ${config.fileMono}`}>
|
|
261
345
|
{file}
|
|
@@ -2,9 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
const CARD_SHADOW = '0 1px 2px rgba(0,0,0,0.03), 0 2px 4px rgba(0,0,0,0.03), 0 4px 8px rgba(0,0,0,0.02)';
|
|
7
|
-
const CARD_SHADOW_HOVER = '0 2px 4px rgba(0,0,0,0.04), 0 4px 8px rgba(0,0,0,0.04), 0 8px 16px rgba(0,0,0,0.03), 0 12px 24px rgba(129,157,159,0.08)';
|
|
5
|
+
import { shadow } from '@/lib/shadows';
|
|
8
6
|
|
|
9
7
|
export interface ChoiceOption {
|
|
10
8
|
id: string;
|
|
@@ -32,22 +30,22 @@ export function GateChoiceCard({
|
|
|
32
30
|
|
|
33
31
|
return (
|
|
34
32
|
<div
|
|
35
|
-
className="bg-white dark:bg-zinc-800
|
|
36
|
-
style={{ boxShadow:
|
|
33
|
+
className="bg-white dark:bg-zinc-800 rounded-xl p-4 transition-shadow duration-200 ease-out"
|
|
34
|
+
style={{ boxShadow: shadow.sm }}
|
|
37
35
|
data-testid="gate-choice-card"
|
|
38
36
|
>
|
|
39
|
-
<div className="flex items-start gap-
|
|
37
|
+
<div className="flex items-start gap-4">
|
|
40
38
|
<span className="text-base flex-shrink-0 mt-0.5">💬</span>
|
|
41
39
|
<div className="flex-1 min-w-0">
|
|
42
|
-
<span className="text-
|
|
40
|
+
<span className="text-base font-semibold text-zinc-700 dark:text-zinc-300">
|
|
43
41
|
Input Needed
|
|
44
42
|
</span>
|
|
45
|
-
<p className="text-
|
|
43
|
+
<p className="text-base mt-1 text-zinc-700 dark:text-zinc-300">
|
|
46
44
|
{question}
|
|
47
45
|
</p>
|
|
48
46
|
|
|
49
47
|
{/* Option cards */}
|
|
50
|
-
<div className="mt-
|
|
48
|
+
<div className="mt-4 space-y-3">
|
|
51
49
|
{options.map((option) => {
|
|
52
50
|
const isSelected = selectedId === option.id;
|
|
53
51
|
const isHovered = hoveredId === option.id;
|
|
@@ -60,35 +58,35 @@ export function GateChoiceCard({
|
|
|
60
58
|
onMouseLeave={() => setHoveredId(null)}
|
|
61
59
|
disabled={disabled && !isSelected}
|
|
62
60
|
className={`
|
|
63
|
-
w-full text-left rounded-xl border p-
|
|
61
|
+
w-full text-left rounded-xl border-2 p-4 transition-[color,background-color,border-color,box-shadow,transform] duration-200 ease-out
|
|
64
62
|
${isSelected
|
|
65
|
-
? 'border-
|
|
63
|
+
? 'border-[#819D9F] dark:border-[#819D9F] bg-[#e8f0f0] dark:bg-[#819D9F]/20 ring-2 ring-[#819D9F]/30'
|
|
66
64
|
: 'border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-600'
|
|
67
65
|
}
|
|
68
66
|
${disabled && !isSelected ? 'opacity-50 cursor-default' : 'cursor-pointer'}
|
|
69
67
|
${!disabled && !isSelected ? 'hover:-translate-y-0.5' : ''}
|
|
70
68
|
`}
|
|
71
69
|
style={{
|
|
72
|
-
boxShadow: isSelected ?
|
|
70
|
+
boxShadow: isSelected ? shadow.lg : isHovered && !disabled ? shadow.lg : shadow.sm,
|
|
73
71
|
}}
|
|
74
72
|
data-testid={`choice-option-${option.id}`}
|
|
75
73
|
>
|
|
76
|
-
<div className="flex items-start gap-
|
|
74
|
+
<div className="flex items-start gap-4">
|
|
77
75
|
{option.emoji && (
|
|
78
76
|
<span className="text-base flex-shrink-0">{option.emoji}</span>
|
|
79
77
|
)}
|
|
80
78
|
<div className="flex-1 min-w-0">
|
|
81
|
-
<div className="flex items-center gap-
|
|
82
|
-
<span className={`text-
|
|
79
|
+
<div className="flex items-center gap-3">
|
|
80
|
+
<span className={`text-base font-medium ${isSelected ? 'text-[#5a7d7f] dark:text-[#a3bfc0]' : 'text-zinc-900 dark:text-zinc-100'}`}>
|
|
83
81
|
{option.label}
|
|
84
82
|
</span>
|
|
85
83
|
{isSelected && (
|
|
86
|
-
<svg className="w-4 h-4 text-
|
|
84
|
+
<svg className="w-4 h-4 text-[#819D9F] flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
87
85
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
88
86
|
</svg>
|
|
89
87
|
)}
|
|
90
88
|
</div>
|
|
91
|
-
<p className={`text-
|
|
89
|
+
<p className={`text-base mt-1 ${isSelected ? 'text-[#5a7d7f]/70 dark:text-[#a3bfc0]/70' : 'text-zinc-500 dark:text-zinc-400'}`}>
|
|
92
90
|
{option.description}
|
|
93
91
|
</p>
|
|
94
92
|
</div>
|