jettypod 4.4.115 → 4.4.118

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.env +7 -0
  2. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +25 -9
  3. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +7 -3
  4. package/apps/dashboard/app/api/tests/run/stream/route.ts +13 -1
  5. package/apps/dashboard/app/api/usage/route.ts +17 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +24 -0
  7. package/apps/dashboard/app/install-claude/page.tsx +8 -6
  8. package/apps/dashboard/app/login/page.tsx +229 -0
  9. package/apps/dashboard/app/page.tsx +5 -3
  10. package/apps/dashboard/app/settings/page.tsx +2 -0
  11. package/apps/dashboard/app/subscribe/page.tsx +11 -0
  12. package/apps/dashboard/app/welcome/page.tsx +23 -0
  13. package/apps/dashboard/components/AppShell.tsx +51 -9
  14. package/apps/dashboard/components/CardMenu.tsx +14 -5
  15. package/apps/dashboard/components/ClaudePanel.tsx +65 -9
  16. package/apps/dashboard/components/ConnectClaudeScreen.tsx +223 -0
  17. package/apps/dashboard/components/DragContext.tsx +73 -64
  18. package/apps/dashboard/components/DraggableCard.tsx +6 -46
  19. package/apps/dashboard/components/GateCard.tsx +21 -0
  20. package/apps/dashboard/components/InstallClaudeScreen.tsx +132 -30
  21. package/apps/dashboard/components/KanbanBoard.tsx +173 -56
  22. package/apps/dashboard/components/PlaceholderCard.tsx +9 -19
  23. package/apps/dashboard/components/ProjectSwitcher.tsx +28 -0
  24. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +34 -3
  25. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +30 -2
  26. package/apps/dashboard/components/SubscribeContent.tsx +191 -0
  27. package/apps/dashboard/components/TipCard.tsx +176 -0
  28. package/apps/dashboard/components/UpgradeBanner.tsx +29 -0
  29. package/apps/dashboard/components/WelcomeScreen.tsx +14 -4
  30. package/apps/dashboard/components/settings/AccountSection.tsx +163 -0
  31. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +292 -29
  32. package/apps/dashboard/contexts/UsageContext.tsx +131 -0
  33. package/apps/dashboard/contexts/usageHelpers.js +9 -0
  34. package/apps/dashboard/electron/ipc-handlers.js +220 -114
  35. package/apps/dashboard/electron/main.js +415 -37
  36. package/apps/dashboard/electron/preload.js +23 -4
  37. package/apps/dashboard/electron/session-manager.js +141 -0
  38. package/apps/dashboard/electron-builder.config.js +3 -5
  39. package/apps/dashboard/lib/claude-process-manager.ts +6 -4
  40. package/apps/dashboard/lib/db-bridge.ts +32 -0
  41. package/apps/dashboard/lib/db.ts +159 -13
  42. package/apps/dashboard/lib/session-state-machine.ts +3 -0
  43. package/apps/dashboard/lib/session-stream-manager.ts +76 -13
  44. package/apps/dashboard/lib/tests.ts +3 -1
  45. package/apps/dashboard/next.config.js +19 -14
  46. package/apps/dashboard/package.json +3 -1
  47. package/apps/dashboard/scripts/upload-to-r2.js +89 -0
  48. package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
  49. package/apps/update-server/package.json +16 -0
  50. package/apps/update-server/schema.sql +31 -0
  51. package/apps/update-server/src/index.ts +1074 -0
  52. package/apps/update-server/tsconfig.json +16 -0
  53. package/apps/update-server/wrangler.toml +35 -0
  54. package/docs/bdd-guidance.md +390 -0
  55. package/jettypod.js +5 -4
  56. package/lib/migrations/027-plan-at-creation-column.js +31 -0
  57. package/lib/migrations/028-ready-for-review-column.js +27 -0
  58. package/lib/schema.js +3 -1
  59. package/lib/seed-onboarding.js +100 -68
  60. package/lib/work-commands/index.js +43 -13
  61. package/lib/work-tracking/index.js +46 -27
  62. package/package.json +1 -1
  63. package/skills-templates/bug-mode/SKILL.md +5 -11
  64. package/skills-templates/request-routing/SKILL.md +24 -11
  65. package/skills-templates/simple-improvement/SKILL.md +35 -19
  66. package/skills-templates/stable-mode/SKILL.md +5 -6
  67. package/templates/bdd-guidance.md +139 -0
  68. package/templates/bdd-scaffolding/wait.js +18 -0
  69. package/templates/bdd-scaffolding/world.js +19 -0
  70. package/.jettypod-backup/work.db +0 -0
  71. package/apps/dashboard/app/access-code/page.tsx +0 -110
  72. package/lib/discovery-checkpoint.js +0 -123
  73. package/skills-templates/project-discovery/SKILL.md +0 -372
@@ -47,15 +47,11 @@ interface DragContextType {
47
47
  draggedItem: WorkItem | null;
48
48
  activeDropZone: string | null;
49
49
  activeEpicZone: string | null;
50
- dragPosition: { x: number; y: number };
51
- draggedCardHeight: number;
52
50
  setDraggedItem: (item: WorkItem | null) => void;
53
51
  registerDropZone: (id: string, info: DropZoneInfo) => void;
54
52
  unregisterDropZone: (id: string) => void;
55
53
  registerEpicDropZone: (id: string, info: EpicDropZoneInfo) => void;
56
54
  unregisterEpicDropZone: (id: string) => void;
57
- registerCardPosition: (id: number, rect: DOMRect) => void;
58
- unregisterCard: (id: number) => void;
59
55
  getCardPositions: () => CardPosition[];
60
56
  }
61
57
 
@@ -64,15 +60,11 @@ const DragContext = createContext<DragContextType>({
64
60
  draggedItem: null,
65
61
  activeDropZone: null,
66
62
  activeEpicZone: null,
67
- dragPosition: { x: 0, y: 0 },
68
- draggedCardHeight: 0,
69
63
  setDraggedItem: () => {},
70
64
  registerDropZone: () => {},
71
65
  unregisterDropZone: () => {},
72
66
  registerEpicDropZone: () => {},
73
67
  unregisterEpicDropZone: () => {},
74
- registerCardPosition: () => {},
75
- unregisterCard: () => {},
76
68
  getCardPositions: () => [],
77
69
  });
78
70
 
@@ -118,12 +110,8 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
118
110
  const [draggedItem, setDraggedItem] = useState<WorkItem | null>(null);
119
111
  const [activeDropZone, setActiveDropZone] = useState<string | null>(null);
120
112
  const [activeEpicZone, setActiveEpicZone] = useState<string | null>(null);
121
- const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
122
- const [draggedCardHeight, setDraggedCardHeight] = useState(0);
123
-
124
113
  const dropZonesRef = useRef<Map<string, DropZoneInfo>>(new Map());
125
114
  const epicDropZonesRef = useRef<Map<string, EpicDropZoneInfo>>(new Map());
126
- const cardPositionsRef = useRef<Map<number, DOMRect>>(new Map());
127
115
  const draggedItemRef = useRef<WorkItem | null>(null);
128
116
  const pointerPositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
129
117
 
@@ -159,22 +147,16 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
159
147
  epicDropZonesRef.current.delete(id);
160
148
  }, []);
161
149
 
162
- const registerCardPosition = useCallback((id: number, rect: DOMRect) => {
163
- cardPositionsRef.current.set(id, rect);
164
- }, []);
165
-
166
- const unregisterCard = useCallback((id: number) => {
167
- // Don't unregister if this is the dragged card
168
- if (draggedItemRef.current?.id === id) {
169
- return;
170
- }
171
- cardPositionsRef.current.delete(id);
172
- }, []);
173
-
150
+ // Read fresh card positions from DOM - avoids stale cached positions
151
+ // that cause jank when cards shift during drag (collapsed dragged card, placeholders)
174
152
  const getCardPositions = useCallback((): CardPosition[] => {
175
153
  const positions: CardPosition[] = [];
176
- cardPositionsRef.current.forEach((rect, id) => {
177
- positions.push({ id, rect });
154
+ const elements = document.querySelectorAll<HTMLElement>('[data-item-id]');
155
+ elements.forEach((el) => {
156
+ const id = Number(el.getAttribute('data-item-id'));
157
+ if (!isNaN(id) && el.offsetHeight > 0) {
158
+ positions.push({ id, rect: el.getBoundingClientRect() });
159
+ }
178
160
  });
179
161
  return positions;
180
162
  }, []);
@@ -189,40 +171,34 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
189
171
  if (item) {
190
172
  setDraggedItem(item);
191
173
  draggedItemRef.current = item;
192
- // Get the card height from the dragging element
193
- const rect = event.active.rect.current.initial;
194
- if (rect) {
195
- setDraggedCardHeight(rect.height);
196
- }
197
174
  }
198
175
  }, []);
199
176
 
200
177
  const handleDragMove = useCallback((event: { activatorEvent: Event; delta: { x: number; y: number } }) => {
201
- // Track pointer position for insertion preview calculations
178
+ // Update ref for reorder calculations (state update handled by native pointermove)
202
179
  const pointerEvent = event.activatorEvent as PointerEvent;
203
180
  if (pointerEvent) {
204
181
  const x = pointerEvent.clientX + event.delta.x;
205
182
  const y = pointerEvent.clientY + event.delta.y;
206
183
  pointerPositionRef.current = { x, y };
207
- setDragPosition({ x, y });
208
184
  }
209
185
  }, []);
210
186
 
211
187
  const handleDragOver = useCallback((event: DragOverEvent) => {
212
188
  const { over, activatorEvent, delta } = event;
213
189
 
214
- // Update pointer position
190
+ // Update pointer ref (state update handled by native pointermove)
215
191
  const pointerEvent = activatorEvent as PointerEvent;
216
192
  if (pointerEvent) {
217
193
  const x = pointerEvent.clientX + delta.x;
218
194
  const y = pointerEvent.clientY + delta.y;
219
195
  pointerPositionRef.current = { x, y };
220
- setDragPosition({ x, y });
221
196
  }
222
197
 
223
198
  if (!over) {
224
- setActiveDropZone(null);
225
- setActiveEpicZone(null);
199
+ // Don't clear zone state on null - collision detection has gaps during drag
200
+ // that cause the insertion preview to flicker/disappear. Zones are properly
201
+ // cleared when drag ends or is cancelled.
226
202
  return;
227
203
  }
228
204
 
@@ -244,7 +220,19 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
244
220
  setActiveDropZone(foundStatusZone);
245
221
  } else if (dropZonesRef.current.has(overId)) {
246
222
  setActiveDropZone(overId);
247
- setActiveEpicZone(null);
223
+ // Epic zones are nested inside status zones, so collision detection
224
+ // frequently returns the status zone instead. Check if pointer is
225
+ // also within an epic zone (mirror of status zone check above).
226
+ let foundEpicZone: string | null = null;
227
+ epicDropZonesRef.current.forEach((info, id) => {
228
+ const rect = info.element.getBoundingClientRect();
229
+ const x = pointerPositionRef.current.x;
230
+ const y = pointerPositionRef.current.y;
231
+ if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
232
+ foundEpicZone = id;
233
+ }
234
+ });
235
+ setActiveEpicZone(foundEpicZone);
248
236
  } else {
249
237
  setActiveDropZone(null);
250
238
  setActiveEpicZone(null);
@@ -254,11 +242,13 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
254
242
  const handleDragEnd = useCallback(async (event: DragEndEvent) => {
255
243
  const { over, activatorEvent, delta } = event;
256
244
 
257
- // Get final pointer position
245
+ // Get final pointer position (both x and y needed for epic zone bounds check)
258
246
  const pointerEvent = activatorEvent as PointerEvent;
259
247
  if (pointerEvent) {
260
- const y = pointerEvent.clientY + delta.y;
261
- pointerPositionRef.current.y = y;
248
+ pointerPositionRef.current = {
249
+ x: pointerEvent.clientX + delta.x,
250
+ y: pointerEvent.clientY + delta.y,
251
+ };
262
252
  }
263
253
 
264
254
  const item = draggedItemRef.current;
@@ -291,27 +281,43 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
291
281
  }
292
282
  }
293
283
  } else if (overId) {
294
- // Dropped on a status zone
295
- const zoneInfo = dropZonesRef.current.get(overId);
296
- if (zoneInfo) {
297
- // Check if this is a status change or a reorder
298
- if (item.status !== zoneInfo.targetStatus) {
299
- // Status change
300
- await zoneInfo.onDrop(item.id, zoneInfo.targetStatus);
301
- } else if (zoneInfo.onReorder) {
302
- // Reorder within same status
303
- await zoneInfo.onReorder(item.id, pointerPositionRef.current.y);
284
+ // Collision detection returned a status zone, but epic zones are nested
285
+ // inside status zones. Check if pointer is actually within an epic zone.
286
+ let resolvedEpicZone: string | null = null;
287
+ epicDropZonesRef.current.forEach((info, id) => {
288
+ const rect = info.element.getBoundingClientRect();
289
+ const x = pointerPositionRef.current.x;
290
+ const y = pointerPositionRef.current.y;
291
+ if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
292
+ resolvedEpicZone = id;
293
+ }
294
+ });
295
+
296
+ if (resolvedEpicZone) {
297
+ // Pointer is within an epic zone - route to epic handler
298
+ const epicZoneInfo = epicDropZonesRef.current.get(resolvedEpicZone);
299
+ if (epicZoneInfo) {
300
+ if (currentEpicId === epicZoneInfo.epicId && epicZoneInfo.onReorder) {
301
+ await epicZoneInfo.onReorder(item.id, pointerPositionRef.current.y);
302
+ } else if (currentEpicId !== epicZoneInfo.epicId) {
303
+ await epicZoneInfo.onEpicAssign(item.id, epicZoneInfo.epicId);
304
+ }
305
+ }
306
+ } else {
307
+ // Truly a status zone drop
308
+ const zoneInfo = dropZonesRef.current.get(overId);
309
+ if (zoneInfo) {
310
+ if (item.status !== zoneInfo.targetStatus) {
311
+ await zoneInfo.onDrop(item.id, zoneInfo.targetStatus);
312
+ } else if (zoneInfo.onReorder) {
313
+ await zoneInfo.onReorder(item.id, pointerPositionRef.current.y);
314
+ }
304
315
  }
305
- } else if (currentEpicId && onRemoveFromEpic) {
306
- // Dropped on unknown zone - remove from epic if applicable
307
- await onRemoveFromEpic(item.id, null);
308
- }
309
- } else {
310
- // Dropped outside all zones
311
- if (currentEpicId && onRemoveFromEpic) {
312
- await onRemoveFromEpic(item.id, null);
313
316
  }
314
317
  }
318
+ // over: null means collision detection missed - treat as no-op.
319
+ // Same gap issue we handle in handleDragOver. Don't remove from epic
320
+ // just because the collision detection had a gap at the moment of drop.
315
321
  } catch (error) {
316
322
  const errorMessage = error instanceof Error ? error.message : 'Failed to complete drop operation';
317
323
  onError?.(errorMessage);
@@ -351,15 +357,11 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
351
357
  draggedItem,
352
358
  activeDropZone,
353
359
  activeEpicZone,
354
- dragPosition,
355
- draggedCardHeight,
356
360
  setDraggedItem: handleSetDraggedItem,
357
361
  registerDropZone,
358
362
  unregisterDropZone,
359
363
  registerEpicDropZone,
360
364
  unregisterEpicDropZone,
361
- registerCardPosition,
362
- unregisterCard,
363
365
  getCardPositions,
364
366
  }}
365
367
  >
@@ -374,7 +376,14 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
374
376
  >
375
377
  {children}
376
378
  {/* Drag overlay - uses @dnd-kit's built-in DragOverlay */}
377
- <DragOverlay dropAnimation={null}>
379
+ <DragOverlay dropAnimation={{
380
+ duration: 150,
381
+ easing: 'ease',
382
+ keyframes: ({ transform }) => [
383
+ { opacity: 1, transform: transform.initial ? `translate3d(${transform.initial.x}px, ${transform.initial.y}px, 0)` : undefined },
384
+ { opacity: 0, transform: transform.initial ? `translate3d(${transform.initial.x}px, ${transform.initial.y}px, 0)` : undefined },
385
+ ],
386
+ }}>
378
387
  {draggedItem && renderDragOverlay ? (
379
388
  <div
380
389
  style={{
@@ -12,8 +12,7 @@ interface DraggableCardProps {
12
12
  }
13
13
 
14
14
  export function DraggableCard({ item, children, disabled = false }: DraggableCardProps) {
15
- const { registerCardPosition, unregisterCard, draggedItem, setDraggedItem } = useDragContext();
16
- const cardRef = useRef<HTMLDivElement>(null);
15
+ const { draggedItem, setDraggedItem } = useDragContext();
17
16
  const prevDisabledRef = useRef(disabled);
18
17
 
19
18
  const {
@@ -33,65 +32,26 @@ export function DraggableCard({ item, children, disabled = false }: DraggableCar
33
32
  const isNowDisabled = disabled;
34
33
  prevDisabledRef.current = disabled;
35
34
 
36
- // If this card was being dragged and just became disabled, cancel the drag
37
35
  if (wasEnabled && isNowDisabled && draggedItem?.id === item.id) {
38
36
  setDraggedItem(null);
39
37
  }
40
38
  }, [disabled, draggedItem, item.id, setDraggedItem]);
41
39
 
42
- // Register card position for optimized reorder calculations
43
- useEffect(() => {
44
- if (disabled || !cardRef.current) return;
45
-
46
- const updatePosition = () => {
47
- if (cardRef.current) {
48
- registerCardPosition(item.id, cardRef.current.getBoundingClientRect());
49
- }
50
- };
51
-
52
- // Initial registration
53
- updatePosition();
54
-
55
- // Update on resize/layout changes
56
- const resizeObserver = new ResizeObserver(updatePosition);
57
- resizeObserver.observe(cardRef.current);
58
-
59
- // Update on scroll (positions are viewport-relative)
60
- const scrollHandler = () => updatePosition();
61
- window.addEventListener('scroll', scrollHandler, true);
62
-
63
- return () => {
64
- unregisterCard(item.id);
65
- resizeObserver.disconnect();
66
- window.removeEventListener('scroll', scrollHandler, true);
67
- };
68
- }, [item.id, disabled, registerCardPosition, unregisterCard]);
69
-
70
- // Combine refs
71
- const setRefs = (node: HTMLDivElement | null) => {
72
- setNodeRef(node);
73
- (cardRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
74
- };
75
-
76
40
  if (disabled) {
77
41
  return <>{children}</>;
78
42
  }
79
43
 
80
- // When dragging, hide the original card (overlay shows the dragged version)
44
+ // When dragging, fade the original card but keep its space to prevent layout jumping.
45
+ // The DragOverlay shows the card at the cursor; the thin insertion line shows where it'll land.
81
46
  const style = {
82
- // Only apply transform when not using drag overlay (which we are using)
83
- // So we keep the original position and hide it instead
84
- opacity: isDragging ? 0 : 1,
85
- height: isDragging ? 0 : 'auto',
86
- overflow: isDragging ? 'hidden' : 'visible',
87
- padding: isDragging ? 0 : undefined,
88
- marginBottom: isDragging ? 0 : undefined,
47
+ opacity: isDragging ? 0.2 : 1,
48
+ transition: 'opacity 150ms ease',
89
49
  touchAction: 'none' as const,
90
50
  };
91
51
 
92
52
  return (
93
53
  <div
94
- ref={setRefs}
54
+ ref={setNodeRef}
95
55
  style={style}
96
56
  className="cursor-grab active:cursor-grabbing"
97
57
  data-draggable="true"
@@ -5,6 +5,7 @@ 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';
8
9
 
9
10
  // Gate display configuration matching JettyPod design system
10
11
  const GATE_CONFIG: Record<string, {
@@ -117,6 +118,17 @@ const GATE_CONFIG: Record<string, {
117
118
  darkText: 'dark:text-zinc-300',
118
119
  fileMono: 'text-indigo-600',
119
120
  },
121
+ 'tip': {
122
+ emoji: '💡',
123
+ label: 'Tip',
124
+ bg: 'bg-teal-50',
125
+ darkBg: 'dark:bg-teal-900/20',
126
+ border: 'border-teal-200',
127
+ darkBorder: 'dark:border-teal-800',
128
+ text: 'text-zinc-700',
129
+ darkText: 'dark:text-zinc-300',
130
+ fileMono: 'text-teal-600',
131
+ },
120
132
  };
121
133
 
122
134
  const DEFAULT_CONFIG = {
@@ -207,6 +219,15 @@ export function GateCard({ message, isLatest = false, onAnswerQuestion, answered
207
219
  return <ModeStartCard gateType={gateType} />;
208
220
  }
209
221
 
222
+ // Tip gates render as dismissible guidance cards
223
+ if (gateType === 'tip') {
224
+ const tipId = (gateData.id as string) || `tip-${message.timestamp}`;
225
+ const icon = (gateData.icon as string) || '💡';
226
+ const title = (gateData.title as string) || 'Tip';
227
+ const body = (gateData.body as string) || '';
228
+ return <TipCard tipId={tipId} icon={icon} title={title} body={body} />;
229
+ }
230
+
210
231
  // Question gates render as interactive choice cards
211
232
  if (gateType === 'question') {
212
233
  const question = (gateData.question as string) || 'A decision is needed';
@@ -1,20 +1,74 @@
1
1
  'use client';
2
2
 
3
+ import { useState, useEffect } from 'react';
3
4
  import Image from 'next/image';
4
5
 
6
+ const statusMessages = [
7
+ "Downloading Claude Code...",
8
+ "Setting Up Environment...",
9
+ "Configuring Permissions...",
10
+ "Almost There...",
11
+ ];
12
+
5
13
  interface InstallClaudeScreenProps {
6
14
  onInstall: () => void;
7
15
  isInstalling: boolean;
8
- installProgress?: string;
16
+ isSuccess: boolean;
9
17
  }
10
18
 
11
19
  export function InstallClaudeScreen({
12
20
  onInstall,
13
21
  isInstalling,
14
- installProgress,
22
+ isSuccess,
15
23
  }: InstallClaudeScreenProps) {
24
+ const [messageIndex, setMessageIndex] = useState(0);
25
+ const [progress, setProgress] = useState(0);
26
+
27
+ useEffect(() => {
28
+ if (!isInstalling || isSuccess) return;
29
+ const interval = setInterval(() => {
30
+ setMessageIndex((prev) => (prev + 1) % statusMessages.length);
31
+ }, 2500);
32
+ return () => clearInterval(interval);
33
+ }, [isInstalling, isSuccess]);
34
+
35
+ useEffect(() => {
36
+ if (!isInstalling || isSuccess) return;
37
+ const interval = setInterval(() => {
38
+ setProgress((prev) => Math.min(prev + 2, 90));
39
+ }, 300);
40
+ return () => clearInterval(interval);
41
+ }, [isInstalling, isSuccess]);
42
+
43
+ useEffect(() => {
44
+ if (isSuccess) setProgress(100);
45
+ }, [isSuccess]);
46
+
16
47
  return (
17
48
  <div className="flex flex-col items-center justify-center min-h-screen bg-white dark:bg-zinc-900 p-8">
49
+ <style jsx>{`
50
+ @keyframes pulse {
51
+ 0%, 100% { opacity: 1; transform: scale(1); }
52
+ 50% { opacity: 0.5; transform: scale(1.1); }
53
+ }
54
+ @keyframes drawCheck {
55
+ to { stroke-dashoffset: 0; }
56
+ }
57
+ @keyframes fadeIn {
58
+ from { opacity: 0; transform: translateY(8px); }
59
+ to { opacity: 1; transform: translateY(0); }
60
+ }
61
+ .pulse-dot {
62
+ animation: pulse 1.5s ease-in-out infinite;
63
+ }
64
+ .checkmark-draw {
65
+ animation: drawCheck 0.6s ease-out forwards;
66
+ }
67
+ .fade-in {
68
+ animation: fadeIn 0.4s ease-out forwards;
69
+ }
70
+ `}</style>
71
+
18
72
  <div className="max-w-md w-full space-y-8">
19
73
  {/* Logo */}
20
74
  <div className="flex flex-col items-center space-y-4">
@@ -25,27 +79,73 @@ export function InstallClaudeScreen({
25
79
  height={40}
26
80
  priority
27
81
  />
28
- <h1 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100 text-center">
29
- Claude Code Required
30
- </h1>
31
- <p className="text-zinc-500 dark:text-zinc-400 text-center">
32
- JettyPod requires Claude Code to be installed.
33
- Click below to install it automatically.
34
- </p>
82
+ {!isInstalling && !isSuccess && (
83
+ <>
84
+ <h1 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100 text-center">
85
+ Claude Code Required
86
+ </h1>
87
+ <p className="text-zinc-500 dark:text-zinc-400 text-center">
88
+ JettyPod requires Claude Code to be installed.
89
+ Click below to install it automatically.
90
+ </p>
91
+ </>
92
+ )}
35
93
  </div>
36
94
 
37
- {/* Install Button or Progress */}
95
+ {/* Install Button / Progress / Success */}
38
96
  <div className="pt-4">
39
- {isInstalling ? (
40
- <div className="space-y-4" data-testid="install-progress">
41
- <div className="w-full py-3 px-6 rounded-xl font-medium text-center bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300">
42
- Installing Claude Code...
97
+ {isSuccess ? (
98
+ <div className="flex flex-col items-center space-y-6 fade-in" data-testid="install-success">
99
+ {/* Animated Checkmark */}
100
+ <div
101
+ className="w-20 h-20 rounded-full flex items-center justify-center"
102
+ style={{ backgroundColor: '#819D9F' }}
103
+ >
104
+ <svg width="40" height="40" viewBox="0 0 40 40" fill="none">
105
+ <polyline
106
+ points="10,20 18,28 30,12"
107
+ stroke="white"
108
+ strokeWidth="3"
109
+ strokeLinecap="round"
110
+ strokeLinejoin="round"
111
+ fill="none"
112
+ strokeDasharray="40"
113
+ strokeDashoffset="40"
114
+ className="checkmark-draw"
115
+ />
116
+ </svg>
117
+ </div>
118
+
119
+ <h2 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100 text-center">
120
+ Claude Code Installed
121
+ </h2>
122
+ <p className="text-zinc-500 dark:text-zinc-400 text-center">
123
+ Redirecting you to connect your account...
124
+ </p>
125
+ </div>
126
+ ) : isInstalling ? (
127
+ <div className="space-y-6" data-testid="install-progress">
128
+ {/* Pulse dot + status message */}
129
+ <div className="flex items-center justify-center space-x-3">
130
+ <div
131
+ className="w-3 h-3 rounded-full pulse-dot"
132
+ style={{ backgroundColor: '#819D9F' }}
133
+ />
134
+ <span className="text-zinc-600 dark:text-zinc-300 font-medium">
135
+ {statusMessages[messageIndex]}
136
+ </span>
137
+ </div>
138
+
139
+ {/* Progress bar */}
140
+ <div className="w-full h-1 bg-zinc-200 dark:bg-zinc-700 rounded-full overflow-hidden">
141
+ <div
142
+ className="h-full rounded-full transition-all duration-300 ease-out"
143
+ style={{
144
+ width: `${progress}%`,
145
+ background: 'linear-gradient(90deg, #c8d9da, #819D9F)',
146
+ }}
147
+ />
43
148
  </div>
44
- {installProgress && (
45
- <pre className="bg-zinc-100 dark:bg-zinc-800 p-4 rounded-lg text-sm text-zinc-600 dark:text-zinc-300 overflow-auto max-h-48">
46
- {installProgress}
47
- </pre>
48
- )}
49
149
  </div>
50
150
  ) : (
51
151
  <button
@@ -73,18 +173,20 @@ export function InstallClaudeScreen({
73
173
  )}
74
174
  </div>
75
175
 
76
- {/* Info Section */}
77
- <div className="pt-8 space-y-4">
78
- <div className="border border-zinc-200 dark:border-zinc-700 rounded-lg p-4 text-zinc-500 dark:text-zinc-400 text-sm">
79
- <p>
80
- <strong className="text-zinc-700 dark:text-zinc-300">What is Claude Code?</strong>
81
- </p>
82
- <p className="mt-2">
83
- Claude Code is an AI-powered coding assistant by Anthropic.
84
- JettyPod uses it to help you plan and build software.
85
- </p>
176
+ {/* Info Section - only show when idle */}
177
+ {!isInstalling && !isSuccess && (
178
+ <div className="pt-8 space-y-4">
179
+ <div className="border border-zinc-200 dark:border-zinc-700 rounded-lg p-4 text-zinc-500 dark:text-zinc-400 text-sm">
180
+ <p>
181
+ <strong className="text-zinc-700 dark:text-zinc-300">What is Claude Code?</strong>
182
+ </p>
183
+ <p className="mt-2">
184
+ Claude Code is an AI-powered coding assistant by Anthropic.
185
+ JettyPod uses it to help you plan and build software.
186
+ </p>
187
+ </div>
86
188
  </div>
87
- </div>
189
+ )}
88
190
  </div>
89
191
  </div>
90
192
  );