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
@@ -7,14 +7,17 @@ interface CardMenuProps {
7
7
  itemId: number;
8
8
  itemTitle?: string;
9
9
  itemType?: string;
10
+ itemDescription?: string | null;
11
+ conversational?: boolean;
10
12
  currentStatus: string;
11
13
  onStatusChange: (id: number, newStatus: string) => Promise<void>;
12
- onTriggerClaude?: (id: number, title: string, type: string) => void;
14
+ onTriggerClaude?: (id: number, title: string, type: string, conversational?: boolean, description?: string | null) => void;
13
15
  hasActiveSession?: boolean;
14
16
  onOpenSession?: (id: string) => void;
17
+ usageAllowed?: boolean;
15
18
  }
16
19
 
17
- export function CardMenu({ itemId, itemTitle = '', itemType = 'chore', currentStatus, onStatusChange, onTriggerClaude, hasActiveSession, onOpenSession }: CardMenuProps) {
20
+ export function CardMenu({ itemId, itemTitle = '', itemType = 'chore', itemDescription, conversational = false, currentStatus, onStatusChange, onTriggerClaude, hasActiveSession, onOpenSession, usageAllowed = true }: CardMenuProps) {
18
21
  const [isOpen, setIsOpen] = useState(false);
19
22
  const [error, setError] = useState<string | null>(null);
20
23
  const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
@@ -74,7 +77,7 @@ export function CardMenu({ itemId, itemTitle = '', itemType = 'chore', currentSt
74
77
  await onStatusChange(itemId, 'in_progress');
75
78
  // Trigger Claude panel after status change succeeds
76
79
  if (onTriggerClaude) {
77
- onTriggerClaude(itemId, itemTitle, itemType);
80
+ onTriggerClaude(itemId, itemTitle, itemType, conversational, itemDescription);
78
81
  }
79
82
  } catch (err) {
80
83
  setError(err instanceof Error ? err.message : 'Failed to update status');
@@ -102,10 +105,16 @@ export function CardMenu({ itemId, itemTitle = '', itemType = 'chore', currentSt
102
105
  {(currentStatus === 'backlog' || currentStatus === 'cancelled') && (
103
106
  <button
104
107
  onClick={handleStart}
105
- className="w-full px-3 py-2 text-left text-sm text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-2"
108
+ disabled={!usageAllowed}
109
+ className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 ${
110
+ usageAllowed
111
+ ? 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
112
+ : 'text-zinc-400 dark:text-zinc-600 cursor-not-allowed'
113
+ }`}
106
114
  data-testid="start-button"
115
+ title={!usageAllowed ? 'Weekly usage limit reached' : undefined}
107
116
  >
108
- <span className="text-blue-500">▶</span>
117
+ <span className={usageAllowed ? 'text-blue-500' : 'text-zinc-400 dark:text-zinc-600'}>▶</span>
109
118
  Start
110
119
  </button>
111
120
  )}
@@ -10,6 +10,39 @@ import { GateCard } from './GateCard';
10
10
  import type { SessionItem } from './SessionList';
11
11
  import type { Session } from '../contexts/ClaudeSessionContext';
12
12
 
13
+ // Tool name → human-friendly verb mapping for activity indicator
14
+ const TOOL_VERBS: Record<string, string> = {
15
+ Read: 'Reading',
16
+ Grep: 'Searching for',
17
+ Glob: 'Finding files matching',
18
+ Bash: 'Running',
19
+ Edit: 'Editing',
20
+ Write: 'Writing',
21
+ Task: 'Delegating',
22
+ WebFetch: 'Fetching',
23
+ WebSearch: 'Searching web for',
24
+ };
25
+
26
+ function extractFilename(path: string): string {
27
+ return path.split('/').pop() || path;
28
+ }
29
+
30
+ function humanizeToolCall(toolName: string, param: string): string {
31
+ const verb = TOOL_VERBS[toolName] || toolName;
32
+ if (['Read', 'Edit', 'Write'].includes(toolName)) {
33
+ return `${verb} ${extractFilename(param)}...`;
34
+ }
35
+ if (toolName === 'Bash') {
36
+ const short = param.length > 40 ? param.slice(0, 40) : param;
37
+ return `${verb} ${short}...`;
38
+ }
39
+ if (['Grep', 'Glob', 'WebSearch'].includes(toolName)) {
40
+ const short = param.length > 30 ? param.slice(0, 30) : param;
41
+ return `${verb} ${short}...`;
42
+ }
43
+ return `${verb} ${param}...`;
44
+ }
45
+
13
46
  // Unescape content that may have literal \n, \t, \r from JSON stringification
14
47
  function unescapeContent(content: string | undefined): string {
15
48
  if (!content) return '';
@@ -187,6 +220,7 @@ interface ClaudePanelProps {
187
220
  error: string | null;
188
221
  exitCode: number | null;
189
222
  canRetry: boolean;
223
+ queuedMessage?: { message: string; images?: unknown[] } | null;
190
224
  onClose: () => void;
191
225
  onRetry: () => void;
192
226
  onSendMessage: (message: string, images?: Array<{ type: string; dataUrl: string }>) => void;
@@ -213,6 +247,7 @@ export function ClaudePanel({
213
247
  error,
214
248
  exitCode,
215
249
  canRetry,
250
+ queuedMessage,
216
251
  onClose,
217
252
  onRetry,
218
253
  onSendMessage,
@@ -228,6 +263,9 @@ export function ClaudePanel({
228
263
  }: ClaudePanelProps) {
229
264
  const contentRef = useRef<HTMLDivElement>(null);
230
265
  const hasGates = messages.some(m => m.type === 'gate');
266
+ // Force detail view when no user messages (e.g., welcome session with static content)
267
+ const hasUserMessages = messages.some(m => m.type === 'user');
268
+ const effectiveNarratedMode = hasUserMessages ? narratedMode : false;
231
269
 
232
270
  // Track answered question gates by timestamp → selected option id
233
271
  const [answeredQuestions, setAnsweredQuestions] = useState<Map<number, string>>(new Map());
@@ -247,7 +285,7 @@ export function ClaudePanel({
247
285
  // Reset expanded state when toggling between summary/detail views
248
286
  useEffect(() => {
249
287
  setExpandedIndices(new Set());
250
- }, [narratedMode]);
288
+ }, [effectiveNarratedMode]);
251
289
 
252
290
  // Drag-and-drop state lifted to panel level so the entire panel is a drop target
253
291
  const [isDragging, setIsDragging] = useState(false);
@@ -367,14 +405,14 @@ export function ClaudePanel({
367
405
  <button
368
406
  onClick={onToggleNarratedMode}
369
407
  className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
370
- narratedMode
408
+ effectiveNarratedMode
371
409
  ? 'bg-blue-100 text-blue-700 hover:bg-blue-200'
372
410
  : 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200'
373
411
  }`}
374
- aria-label={narratedMode ? 'Show full conversation' : 'Show summary view'}
412
+ aria-label={effectiveNarratedMode ? 'Show full conversation' : 'Show summary view'}
375
413
  data-testid="narrated-mode-toggle"
376
414
  >
377
- {narratedMode ? 'Summary' : 'Details'}
415
+ {effectiveNarratedMode ? 'Summary' : 'Details'}
378
416
  </button>
379
417
  )}
380
418
  <button
@@ -540,7 +578,7 @@ export function ClaudePanel({
540
578
  </div>
541
579
  ) : (
542
580
  <>
543
- {narratedMode ? (
581
+ {effectiveNarratedMode ? (
544
582
  /* Narrated mode: show gate cards, user messages, assistant text, and a working indicator */
545
583
  <>
546
584
  {(() => {
@@ -591,8 +629,14 @@ export function ClaudePanel({
591
629
  {status === 'streaming' && (
592
630
  <div className="flex items-center gap-2 text-xs text-zinc-400 py-2">
593
631
  <span className="w-1.5 h-1.5 bg-blue-400 rounded-full animate-pulse" />
594
- Working...
595
632
  <ElapsedTimer isStreaming={true} timerKey={`${activeSessionId ?? 'default'}-${messages.filter(m => m.type === 'user').length}`} />
633
+ {(() => {
634
+ const lastToolUse = [...messages].reverse().find(m => m.type === 'tool_use');
635
+ if (!lastToolUse || !lastToolUse.tool_name) return 'Working...';
636
+ const firstParamValue = lastToolUse.tool_input ? Object.values(lastToolUse.tool_input)[0] : null;
637
+ const param = typeof firstParamValue === 'string' ? firstParamValue : '';
638
+ return humanizeToolCall(lastToolUse.tool_name, param);
639
+ })()}
596
640
  </div>
597
641
  )}
598
642
  </>
@@ -604,7 +648,8 @@ export function ClaudePanel({
604
648
  const lastUserMessageIndex = filteredMessages.findLastIndex(m => m.type === 'user');
605
649
  const userMessageCount = filteredMessages.filter(m => m.type === 'user').length;
606
650
  const lastAssistantIndex = filteredMessages.findLastIndex(m => m.type === 'assistant' || m.type === 'text');
607
- const hasIntermediates = filteredMessages.filter(m => m.type === 'assistant' || m.type === 'text').length > 1;
651
+ // Only show collapse UI when there's an actual conversation (user messages exist)
652
+ const hasIntermediates = userMessageCount > 0 && filteredMessages.filter(m => m.type === 'assistant' || m.type === 'text').length > 1;
608
653
 
609
654
  const allExpanded = hasIntermediates && filteredMessages.every((m, i) =>
610
655
  (m.type !== 'assistant' && m.type !== 'text') || i === lastAssistantIndex || expandedIndices.has(i)
@@ -638,7 +683,8 @@ export function ClaudePanel({
638
683
  {filteredMessages.map((message, index) => {
639
684
  const isAssistant = message.type === 'assistant' || message.type === 'text';
640
685
  const isFinal = isAssistant && index === lastAssistantIndex;
641
- const isIntermediate = isAssistant && !isFinal;
686
+ // Don't collapse when no user messages (e.g., welcome session with static content)
687
+ const isIntermediate = isAssistant && !isFinal && userMessageCount > 0;
642
688
  const isExpanded = expandedIndices.has(index);
643
689
 
644
690
  // Get first line for collapsed summary
@@ -744,6 +790,16 @@ export function ClaudePanel({
744
790
  What's next?
745
791
  </div>
746
792
  )}
793
+ {/* Queued message — shown below status indicator while Claude is processing */}
794
+ {queuedMessage && (
795
+ <div className="bg-blue-50 border border-blue-100 rounded-lg p-3 opacity-60" data-testid="queued-message">
796
+ <div className="flex items-center gap-1.5 mb-1">
797
+ <UserIcon />
798
+ <span className="text-xs text-zinc-400">Queued</span>
799
+ </div>
800
+ <div className="text-sm text-zinc-700 whitespace-pre-wrap">{queuedMessage.message}</div>
801
+ </div>
802
+ )}
747
803
  </>
748
804
  )}
749
805
  </div>
@@ -974,7 +1030,7 @@ function ElapsedTimer({ isStreaming, timerKey }: { isStreaming: boolean; timerKe
974
1030
  const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
975
1031
 
976
1032
  return (
977
- <div className="text-xs text-zinc-500 mt-1 text-right" data-testid="elapsed-timer">
1033
+ <div className="text-xs text-zinc-500 -translate-y-px" data-testid="elapsed-timer">
978
1034
  {display}
979
1035
  </div>
980
1036
  );
@@ -0,0 +1,223 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef } from 'react';
4
+ import Image from 'next/image';
5
+
6
+ type ConnectState = 'idle' | 'waiting' | 'success' | 'error';
7
+
8
+ const AUTH_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
9
+
10
+ interface ConnectClaudeScreenProps {
11
+ onConnect: () => Promise<{ success: boolean; error?: string }>;
12
+ onCheckAuth: () => Promise<boolean>;
13
+ }
14
+
15
+ export function ConnectClaudeScreen({ onConnect, onCheckAuth }: ConnectClaudeScreenProps) {
16
+ const [state, setState] = useState<ConnectState>('idle');
17
+ const [completedSteps, setCompletedSteps] = useState<number[]>([]);
18
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
19
+ const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
20
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
21
+
22
+ const cleanup = () => {
23
+ if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
24
+ if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
25
+ };
26
+
27
+ useEffect(() => {
28
+ return cleanup;
29
+ }, []);
30
+
31
+ const handleSuccess = () => {
32
+ cleanup();
33
+ setCompletedSteps([1, 2, 3]);
34
+ setState('success');
35
+ setTimeout(() => {
36
+ window.location.href = '/';
37
+ }, 1500);
38
+ };
39
+
40
+ const handleError = (message: string) => {
41
+ cleanup();
42
+ setCompletedSteps([]);
43
+ setErrorMessage(message);
44
+ setState('error');
45
+ };
46
+
47
+ const handleConnect = async () => {
48
+ setState('waiting');
49
+ setCompletedSteps([1]);
50
+ setErrorMessage(null);
51
+
52
+ // Trigger claude login (opens browser)
53
+ let result: { success: boolean; error?: string };
54
+ try {
55
+ result = await onConnect();
56
+ } catch {
57
+ handleError('Failed to start Claude login. Please try again.');
58
+ return;
59
+ }
60
+
61
+ if (result.success) {
62
+ handleSuccess();
63
+ return;
64
+ }
65
+
66
+ // Start polling in case login succeeds via browser after process exits
67
+ pollRef.current = setInterval(async () => {
68
+ try {
69
+ const authed = await onCheckAuth();
70
+ if (authed) {
71
+ handleSuccess();
72
+ }
73
+ } catch {
74
+ // Polling check failed — ignore and retry on next interval
75
+ }
76
+ }, 2000);
77
+
78
+ // Set timeout to stop polling after AUTH_TIMEOUT_MS
79
+ timeoutRef.current = setTimeout(() => {
80
+ handleError('Authentication timed out. Make sure you completed sign-in in the browser, then try again.');
81
+ }, AUTH_TIMEOUT_MS);
82
+ };
83
+
84
+ const steps = [
85
+ 'Open Anthropic login in browser',
86
+ 'Sign in to your Anthropic account',
87
+ 'Return to JettyPod — ready to go',
88
+ ];
89
+
90
+ return (
91
+ <div className="flex flex-col items-center justify-center min-h-screen bg-white dark:bg-zinc-900 p-8">
92
+ <div className="max-w-md w-full space-y-8">
93
+ {/* Logo */}
94
+ <div className="flex flex-col items-center space-y-4">
95
+ <Image
96
+ src="/jettypod_wordmark.png"
97
+ alt="JettyPod"
98
+ width={160}
99
+ height={40}
100
+ priority
101
+ />
102
+ <h1 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100 text-center">
103
+ Connect Claude Code
104
+ </h1>
105
+ <p className="text-zinc-500 dark:text-zinc-400 text-center">
106
+ Claude Code needs to be connected to your Anthropic account.
107
+ This will open your browser to sign in.
108
+ </p>
109
+ </div>
110
+
111
+ {/* Error Banner */}
112
+ {errorMessage && (
113
+ <div
114
+ className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded-lg text-sm"
115
+ data-testid="connect-error"
116
+ >
117
+ {errorMessage}
118
+ </div>
119
+ )}
120
+
121
+ {/* Progress Stepper */}
122
+ <div className="flex flex-col gap-4">
123
+ {steps.map((label, i) => {
124
+ const stepNum = i + 1;
125
+ const isDone = completedSteps.includes(stepNum);
126
+ const isActive = !isDone && (
127
+ (state === 'idle' && stepNum === 1) ||
128
+ (state === 'waiting' && stepNum === Math.max(...completedSteps) + 1)
129
+ );
130
+
131
+ return (
132
+ <div
133
+ key={stepNum}
134
+ className={`flex items-center gap-3 text-sm ${
135
+ isDone ? 'text-zinc-900 dark:text-zinc-100' :
136
+ isActive ? 'text-zinc-900 dark:text-zinc-100 font-medium' :
137
+ 'text-zinc-400 dark:text-zinc-500'
138
+ }`}
139
+ >
140
+ <span
141
+ className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold flex-shrink-0 transition-all duration-300 ${
142
+ isDone ? 'bg-green-400 text-white' :
143
+ isActive ? 'bg-[#c8d9da] text-[#3d4d4e]' :
144
+ 'bg-zinc-200 dark:bg-zinc-700 text-zinc-400 dark:text-zinc-500'
145
+ }`}
146
+ >
147
+ {isDone ? '✓' : stepNum}
148
+ </span>
149
+ <span>{label}</span>
150
+ </div>
151
+ );
152
+ })}
153
+ </div>
154
+
155
+ {/* Connect Button */}
156
+ <div className="pt-4">
157
+ {state === 'success' ? (
158
+ <div
159
+ className="w-full py-3 px-6 rounded-xl font-medium text-center bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400"
160
+ data-testid="connect-success"
161
+ >
162
+ ✓ Connected!
163
+ </div>
164
+ ) : (
165
+ <button
166
+ onClick={handleConnect}
167
+ disabled={state === 'waiting'}
168
+ className="w-full py-3 px-6 rounded-xl font-medium transition-all duration-200 hover:-translate-y-1 hover:scale-[1.01] active:translate-y-0 active:scale-100 disabled:opacity-60 disabled:transform-none disabled:cursor-default"
169
+ style={{
170
+ cursor: state === 'waiting' ? 'default' : 'pointer',
171
+ background: 'linear-gradient(145deg, #ffffff 0%, #faf9f7 10%, #f0f4f4 35%, #c8d9da 55%, #819D9F 90%)',
172
+ color: '#3d4d4e',
173
+ boxShadow: `
174
+ 0 1px 1px rgba(0, 0, 0, 0.02),
175
+ 0 2px 4px rgba(0, 0, 0, 0.03),
176
+ 0 6px 12px rgba(0, 0, 0, 0.05),
177
+ 0 12px 24px rgba(0, 0, 0, 0.06),
178
+ 0 20px 40px rgba(129, 157, 159, 0.2),
179
+ 0 32px 64px rgba(129, 157, 159, 0.18),
180
+ inset 0 2px 4px rgba(255, 255, 255, 1),
181
+ inset 0 -2px 4px rgba(129, 157, 159, 0.05)
182
+ `,
183
+ }}
184
+ data-testid="connect-claude-button"
185
+ >
186
+ {state === 'waiting' ? (
187
+ <span className="flex items-center justify-center gap-2">
188
+ <span className="inline-block w-4 h-4 border-2 border-[#c8d9da] border-t-[#3d4d4e] rounded-full animate-spin" />
189
+ Waiting for login...
190
+ </span>
191
+ ) : state === 'error' ? (
192
+ 'Try Again'
193
+ ) : (
194
+ 'Connect Claude Code'
195
+ )}
196
+ </button>
197
+ )}
198
+ </div>
199
+
200
+ {/* Status text */}
201
+ {state === 'waiting' && (
202
+ <p className="text-sm text-zinc-400 dark:text-zinc-500 text-center">
203
+ A browser window should have opened. Complete the sign-in there.
204
+ </p>
205
+ )}
206
+
207
+ {/* Info Section */}
208
+ <div className="pt-8 space-y-4">
209
+ <div className="border border-zinc-200 dark:border-zinc-700 rounded-lg p-4 text-zinc-500 dark:text-zinc-400 text-sm">
210
+ <p>
211
+ <strong className="text-zinc-700 dark:text-zinc-300">Why do I need this?</strong>
212
+ </p>
213
+ <p className="mt-2">
214
+ JettyPod uses Claude Code under the hood. Claude Code requires its own
215
+ Anthropic account to work. This is a one-time setup — you won&apos;t need to
216
+ do this again on this computer.
217
+ </p>
218
+ </div>
219
+ </div>
220
+ </div>
221
+ </div>
222
+ );
223
+ }