jettypod 4.4.116 → 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 -25
  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 +96 -17
  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
package/.env ADDED
@@ -0,0 +1,7 @@
1
+ CLOUDFLARE_API_TOKEN=qaygaUeCLS8RQA5FVsJxnsA02c8b8qWyiLgNSM1X
2
+ STRIPE_SECRET_KEY=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
3
+ STRIPE_WEBHOOK_SECRET=whsec_pkup32kvrcf3g5h8VmtjLfYU9WIWdAb6
4
+ GOOGLE_CLIENT_ID=172847259733-r87e20pjv97290kuusm7ov7uams515mm.apps.googleusercontent.com
5
+ GOOGLE_CLIENT_SECRET=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
6
+ JWT_SECRET=bKYWqg7IR45K9CrA5BRmzXPQoWK6Vv4okZKpGmYD6o4=
7
+ RESEND_API_KEY=re_Tg7gAVGE_LVKhjnyLkpDgYmkzQvYXmCwc
@@ -2,7 +2,7 @@ import { spawnSync } from 'child_process';
2
2
  import { NextRequest, NextResponse } from 'next/server';
3
3
  import path from 'path';
4
4
  import fs from 'fs';
5
- import { appendSessionContentByWorkItem, getSessionContentByWorkItem, ConversationTurn } from '@/lib/db';
5
+ import { appendSessionContentByWorkItem, getSessionContentByWorkItem, getWorkItem, ConversationTurn } from '@/lib/db';
6
6
  import {
7
7
  getOrCreateProcess,
8
8
  sendMessage as sendProcessMessage,
@@ -51,9 +51,14 @@ function buildContextPrefix(history: ConversationTurn[]): string {
51
51
  return `[Session context restored — your process was restarted. Previous conversation:]\n\n${lines.join('\n\n')}\n\n[End of restored context. New message from user:]\n\n`;
52
52
  }
53
53
 
54
+ // Cache the CLI availability check so we only pay the spawnSync cost once per process lifetime
55
+ let claudeCliAvailable: boolean | null = null;
56
+
54
57
  function isClaudeCliAvailable(): boolean {
58
+ if (claudeCliAvailable !== null) return claudeCliAvailable;
55
59
  const result = spawnSync('which', ['claude'], { encoding: 'utf-8' });
56
- return result.status === 0 && result.stdout.trim().length > 0;
60
+ claudeCliAvailable = result.status === 0 && result.stdout.trim().length > 0;
61
+ return claudeCliAvailable;
57
62
  }
58
63
 
59
64
  function isValidWorkItemId(id: string): boolean {
@@ -151,15 +156,26 @@ export async function POST(
151
156
  timestamp: new Date().toISOString()
152
157
  });
153
158
 
154
- // Get or create worktree for this work item
155
- const workItem = {
156
- id: parseInt(workItemId, 10),
157
- title: `Work item ${workItemId}`
158
- };
159
+ // Check if this is a conversational work item (skip worktree)
160
+ const workItemData = getWorkItem(workItemIdNum);
161
+ const isConversational = workItemData?.conversational === 1;
159
162
 
160
163
  const repoPath = getProjectRoot();
161
- const workResult = await worktreeFacade.startWork(workItem, { repoPath });
162
- const claudeCwd = workResult.path;
164
+ let claudeCwd: string;
165
+
166
+ if (isConversational) {
167
+ // Conversational chores run from main repo — no worktree needed
168
+ claudeCwd = repoPath;
169
+ } else {
170
+ // Get or create worktree for this work item
171
+ const workItem = {
172
+ id: parseInt(workItemId, 10),
173
+ title: `Work item ${workItemId}`
174
+ };
175
+ const workResult = await worktreeFacade.startWork(workItem, { repoPath });
176
+ claudeCwd = workResult.path;
177
+ }
178
+
163
179
  const settingsPath = getSettingsPath(repoPath);
164
180
 
165
181
  // Use workItemId prefixed with 'wi-' to avoid collision with standalone session IDs
@@ -2,11 +2,10 @@ import { spawnSync } from 'child_process';
2
2
  import { NextRequest, NextResponse } from 'next/server';
3
3
  import path from 'path';
4
4
  import fs from 'fs';
5
- import { appendSessionContent, getSession, getSessionContent, ConversationTurn } from '@/lib/db';
5
+ import { appendSessionContent, getSessionContent, ConversationTurn } from '@/lib/db';
6
6
  import {
7
7
  getOrCreateProcess,
8
8
  sendMessage as sendProcessMessage,
9
- hasActiveProcess,
10
9
  killProcess,
11
10
  } from '@/lib/claude-process-manager';
12
11
 
@@ -30,9 +29,14 @@ function getSettingsPath(projectRoot: string): string | undefined {
30
29
 
31
30
  export const dynamic = 'force-dynamic';
32
31
 
32
+ // Cache the CLI availability check so we only pay the spawnSync cost once per process lifetime
33
+ let claudeCliAvailable: boolean | null = null;
34
+
33
35
  function isClaudeCliAvailable(): boolean {
36
+ if (claudeCliAvailable !== null) return claudeCliAvailable;
34
37
  const result = spawnSync('which', ['claude'], { encoding: 'utf-8' });
35
- return result.status === 0 && result.stdout.trim().length > 0;
38
+ claudeCliAvailable = result.status === 0 && result.stdout.trim().length > 0;
39
+ return claudeCliAvailable;
36
40
  }
37
41
 
38
42
  /**
@@ -27,8 +27,16 @@ export async function GET(request: NextRequest) {
27
27
  controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
28
28
  }
29
29
 
30
+ // Hold back test_complete events from the runner — we need to ingest
31
+ // results into SQLite BEFORE the frontend refreshes, otherwise it reads stale data.
32
+ let heldTestComplete: Record<string, unknown> | null = null;
33
+
30
34
  const onEvent = (event: { type: string; [key: string]: unknown }) => {
31
35
  const { type, ...data } = event;
36
+ if (type === 'test_complete') {
37
+ heldTestComplete = data;
38
+ return;
39
+ }
32
40
  send(type, data);
33
41
  };
34
42
 
@@ -38,12 +46,16 @@ export async function GET(request: NextRequest) {
38
46
  : runFeature(featureFile, { onEvent, signal: request.signal });
39
47
 
40
48
  run.then((result: { resultsPath: string }) => {
41
- // Ingest results into SQLite
49
+ // Ingest results into SQLite BEFORE sending test_complete
42
50
  try {
43
51
  ingestCucumberResults(result.resultsPath);
44
52
  } catch {
45
53
  // Non-fatal
46
54
  }
55
+ send('test_complete', heldTestComplete || { status: 'fail' });
56
+ controller.close();
57
+ }).catch(() => {
58
+ send('test_complete', { status: 'fail' });
47
59
  controller.close();
48
60
  });
49
61
  },
@@ -0,0 +1,17 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { getWeeklyUsage } from '@/lib/db';
3
+
4
+ export const dynamic = 'force-dynamic';
5
+
6
+ const SAFE_DEFAULT = { used: 0, limit: 20, remaining: 20, allowed: true };
7
+
8
+ export async function GET() {
9
+ try {
10
+ const usage = getWeeklyUsage();
11
+ console.log('[usage] /api/usage responding with', usage);
12
+ return NextResponse.json(usage);
13
+ } catch (err) {
14
+ console.error('[usage] /api/usage ERROR — returning safe default', err);
15
+ return NextResponse.json(SAFE_DEFAULT);
16
+ }
17
+ }
@@ -0,0 +1,24 @@
1
+ 'use client';
2
+
3
+ import { ConnectClaudeScreen } from '@/components/ConnectClaudeScreen';
4
+
5
+ export default function ConnectClaudePage() {
6
+ const handleConnect = async () => {
7
+ if (!window.electronAPI?.isElectron) {
8
+ return { success: false, error: 'Only available in the desktop app.' };
9
+ }
10
+ return await window.electronAPI.claudeCode.login();
11
+ };
12
+
13
+ const handleCheckAuth = async () => {
14
+ if (!window.electronAPI?.isElectron) return false;
15
+ return await window.electronAPI.claudeCode.isAuthenticated();
16
+ };
17
+
18
+ return (
19
+ <ConnectClaudeScreen
20
+ onConnect={handleConnect}
21
+ onCheckAuth={handleCheckAuth}
22
+ />
23
+ );
24
+ }
@@ -5,13 +5,12 @@ import { InstallClaudeScreen } from '@/components/InstallClaudeScreen';
5
5
 
6
6
  export default function InstallClaudePage() {
7
7
  const [isInstalling, setIsInstalling] = useState(false);
8
- const [installProgress, setInstallProgress] = useState<string>('');
8
+ const [isSuccess, setIsSuccess] = useState(false);
9
9
  const [error, setError] = useState<string | null>(null);
10
10
 
11
11
  const handleInstall = async () => {
12
12
  setError(null);
13
13
  setIsInstalling(true);
14
- setInstallProgress('Starting installation...\n');
15
14
 
16
15
  // Check if we're in Electron
17
16
  if (!window.electronAPI?.isElectron) {
@@ -29,10 +28,13 @@ export default function InstallClaudePage() {
29
28
  return;
30
29
  }
31
30
 
32
- setInstallProgress(prev => prev + 'Installation complete!\n');
31
+ // Show success animation
32
+ setIsSuccess(true);
33
33
 
34
- // Navigate to dashboard after successful install
35
- window.location.href = '/';
34
+ // After success animation plays, navigate to connect-claude
35
+ setTimeout(() => {
36
+ window.location.href = '/connect-claude';
37
+ }, 2000);
36
38
  } catch (err) {
37
39
  setError(err instanceof Error ? err.message : 'Installation failed');
38
40
  setIsInstalling(false);
@@ -49,7 +51,7 @@ export default function InstallClaudePage() {
49
51
  <InstallClaudeScreen
50
52
  onInstall={handleInstall}
51
53
  isInstalling={isInstalling}
52
- installProgress={installProgress}
54
+ isSuccess={isSuccess}
53
55
  />
54
56
  </>
55
57
  );
@@ -0,0 +1,229 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef } from 'react';
4
+ import Image from 'next/image';
5
+
6
+ const API_BASE = 'https://jettypod-update-server.spangbaryn2.workers.dev';
7
+
8
+ const buttonGradientStyle = {
9
+ background: 'linear-gradient(145deg, #ffffff 0%, #faf9f7 10%, #f0f4f4 35%, #c8d9da 55%, #819D9F 90%)',
10
+ color: '#3d4d4e',
11
+ boxShadow: `
12
+ 0 1px 1px rgba(0, 0, 0, 0.02),
13
+ 0 2px 4px rgba(0, 0, 0, 0.03),
14
+ 0 6px 12px rgba(0, 0, 0, 0.05),
15
+ 0 12px 24px rgba(0, 0, 0, 0.06),
16
+ 0 20px 40px rgba(129, 157, 159, 0.2),
17
+ 0 32px 64px rgba(129, 157, 159, 0.18),
18
+ inset 0 2px 4px rgba(255, 255, 255, 1),
19
+ inset 0 -2px 4px rgba(129, 157, 159, 0.05)
20
+ `,
21
+ };
22
+
23
+ export default function LoginPage() {
24
+ const [email, setEmail] = useState('');
25
+ const [otpCode, setOtpCode] = useState('');
26
+ const [otpSent, setOtpSent] = useState(false);
27
+ const [isSending, setIsSending] = useState(false);
28
+ const [isVerifying, setIsVerifying] = useState(false);
29
+ const [error, setError] = useState<string | null>(null);
30
+
31
+ // Poll for auth completion after Google sign-in.
32
+ // The deep link handler in main.js saves the token — this polling detects it
33
+ // and navigates to the dashboard even if mainWindow.loadURL doesn't fire.
34
+ const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
35
+
36
+ useEffect(() => {
37
+ return () => {
38
+ if (pollRef.current) clearInterval(pollRef.current);
39
+ };
40
+ }, []);
41
+
42
+ const handleGoogleSignIn = () => {
43
+ if (!window.electronAPI?.isElectron) return;
44
+ window.electronAPI.auth.loginWithGoogle();
45
+
46
+ // Start polling for auth status (token saved by deep link handler)
47
+ pollRef.current = setInterval(async () => {
48
+ try {
49
+ const status = await window.electronAPI!.auth.getStatus();
50
+ if (status.authenticated) {
51
+ if (pollRef.current) clearInterval(pollRef.current);
52
+ window.location.href = '/';
53
+ }
54
+ } catch {
55
+ // Ignore — keep polling
56
+ }
57
+ }, 1000);
58
+ };
59
+
60
+ const handleSendOTP = async (e: React.FormEvent) => {
61
+ e.preventDefault();
62
+ setError(null);
63
+
64
+ if (!email.trim() || !email.includes('@')) {
65
+ setError('Please enter a valid email address.');
66
+ return;
67
+ }
68
+
69
+ setIsSending(true);
70
+
71
+ try {
72
+ const res = await fetch(`${API_BASE}/auth/otp/send`, {
73
+ method: 'POST',
74
+ headers: { 'Content-Type': 'application/json' },
75
+ body: JSON.stringify({ email: email.trim().toLowerCase() }),
76
+ });
77
+
78
+ if (!res.ok) {
79
+ const data = await res.json() as { error?: string };
80
+ setError(data.error || 'Failed to send code.');
81
+ return;
82
+ }
83
+
84
+ setOtpSent(true);
85
+ } catch {
86
+ setError('Failed to send code. Check your connection.');
87
+ } finally {
88
+ setIsSending(false);
89
+ }
90
+ };
91
+
92
+ const handleVerifyOTP = async (e: React.FormEvent) => {
93
+ e.preventDefault();
94
+ setError(null);
95
+
96
+ if (!otpCode.trim()) {
97
+ setError('Please enter the code from your email.');
98
+ return;
99
+ }
100
+
101
+ setIsVerifying(true);
102
+
103
+ try {
104
+ const res = await fetch(`${API_BASE}/auth/otp/verify`, {
105
+ method: 'POST',
106
+ headers: { 'Content-Type': 'application/json' },
107
+ body: JSON.stringify({ email: email.trim().toLowerCase(), code: otpCode.trim() }),
108
+ });
109
+
110
+ if (!res.ok) {
111
+ const data = await res.json() as { error?: string };
112
+ setError(data.error || 'Invalid or expired code.');
113
+ setIsVerifying(false);
114
+ return;
115
+ }
116
+
117
+ const data = await res.json() as { token: string; user: { id: string; email: string; plan: string } };
118
+
119
+ // Save auth state via Electron IPC
120
+ if (window.electronAPI?.isElectron) {
121
+ await window.electronAPI.auth.saveToken(data.token, data.user);
122
+ }
123
+
124
+ window.location.href = '/';
125
+ } catch {
126
+ setError('Failed to verify code. Check your connection.');
127
+ setIsVerifying(false);
128
+ }
129
+ };
130
+
131
+ return (
132
+ <div className="flex flex-col items-center justify-center min-h-screen bg-white dark:bg-zinc-900 p-8">
133
+ <div className="max-w-md w-full space-y-8">
134
+ {/* Logo */}
135
+ <div className="flex flex-col items-center space-y-4">
136
+ <Image
137
+ src="/jettypod_wordmark.png"
138
+ alt="JettyPod"
139
+ width={160}
140
+ height={40}
141
+ priority
142
+ />
143
+ <h1 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100 text-center">
144
+ Sign in to JettyPod
145
+ </h1>
146
+ <p className="text-zinc-500 dark:text-zinc-400 text-center">
147
+ Sign in to get started. Free plan included.
148
+ </p>
149
+ </div>
150
+
151
+ {/* Error */}
152
+ {error && (
153
+ <div 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">
154
+ {error}
155
+ </div>
156
+ )}
157
+
158
+ {/* Google Sign-In */}
159
+ <div className="pt-4">
160
+ <button
161
+ onClick={handleGoogleSignIn}
162
+ 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"
163
+ style={{ cursor: 'pointer', ...buttonGradientStyle }}
164
+ >
165
+ Sign in with Google
166
+ </button>
167
+ </div>
168
+
169
+ {/* Divider */}
170
+ <div className="flex items-center gap-4">
171
+ <div className="flex-1 h-px bg-zinc-200 dark:bg-zinc-700" />
172
+ <span className="text-xs text-zinc-400 dark:text-zinc-500">or</span>
173
+ <div className="flex-1 h-px bg-zinc-200 dark:bg-zinc-700" />
174
+ </div>
175
+
176
+ {/* Email OTP */}
177
+ {!otpSent ? (
178
+ <form onSubmit={handleSendOTP} className="space-y-4">
179
+ <input
180
+ type="email"
181
+ value={email}
182
+ onChange={(e) => setEmail(e.target.value)}
183
+ placeholder="Enter your email"
184
+ disabled={isSending}
185
+ className="w-full px-4 py-3 rounded-xl border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-400 dark:focus:ring-zinc-500 disabled:opacity-50"
186
+ />
187
+ <button
188
+ type="submit"
189
+ disabled={isSending || !email.trim()}
190
+ className="w-full py-3 px-6 rounded-xl font-medium border border-zinc-300 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors disabled:opacity-50 disabled:pointer-events-none"
191
+ >
192
+ {isSending ? 'Sending code...' : 'Sign in with email'}
193
+ </button>
194
+ </form>
195
+ ) : (
196
+ <form onSubmit={handleVerifyOTP} className="space-y-4">
197
+ <p className="text-sm text-zinc-500 dark:text-zinc-400">
198
+ We sent a 6-digit code to <span className="font-medium text-zinc-700 dark:text-zinc-300">{email}</span>
199
+ </p>
200
+ <input
201
+ type="text"
202
+ value={otpCode}
203
+ onChange={(e) => setOtpCode(e.target.value)}
204
+ placeholder="Enter 6-digit code"
205
+ maxLength={6}
206
+ autoFocus
207
+ disabled={isVerifying}
208
+ className="w-full px-4 py-3 rounded-xl border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-400 dark:focus:ring-zinc-500 disabled:opacity-50 text-center text-xl tracking-widest font-mono"
209
+ />
210
+ <button
211
+ type="submit"
212
+ disabled={isVerifying || !otpCode.trim()}
213
+ className="w-full py-3 px-6 rounded-xl font-medium border border-zinc-300 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors disabled:opacity-50 disabled:pointer-events-none"
214
+ >
215
+ {isVerifying ? 'Verifying...' : 'Verify code'}
216
+ </button>
217
+ <button
218
+ type="button"
219
+ onClick={() => { setOtpSent(false); setOtpCode(''); setError(null); }}
220
+ className="w-full text-sm text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors"
221
+ >
222
+ Use a different email
223
+ </button>
224
+ </form>
225
+ )}
226
+ </div>
227
+ </div>
228
+ );
229
+ }
@@ -1,5 +1,5 @@
1
1
  import { redirect } from 'next/navigation';
2
- import { getKanbanData, hasProject } from '@/lib/db';
2
+ import { getKanbanData, hasProject, isBlankProject, getProjectRoot } from '@/lib/db';
3
3
  import { RealTimeKanbanWrapper } from '@/components/RealTimeKanbanWrapper';
4
4
 
5
5
  // Force dynamic rendering - database is only available at runtime
@@ -13,6 +13,8 @@ export default function Home() {
13
13
 
14
14
  try {
15
15
  const data = getKanbanData();
16
+ const projectRoot = getProjectRoot();
17
+ const isBlank = projectRoot ? isBlankProject(projectRoot) : false;
16
18
 
17
19
  // Serialize Map data for client component
18
20
  const serializedData = {
@@ -22,8 +24,8 @@ export default function Home() {
22
24
  };
23
25
 
24
26
  return (
25
- <div className="flex-1 flex flex-col min-h-0 overflow-hidden max-w-6xl w-full mx-auto px-4 py-4">
26
- <RealTimeKanbanWrapper initialData={serializedData} />
27
+ <div className="h-full flex flex-col min-h-0 overflow-hidden max-w-6xl w-full mx-auto px-4 py-4">
28
+ <RealTimeKanbanWrapper initialData={serializedData} isBlank={isBlank} projectPath={projectRoot || ''} />
27
29
  </div>
28
30
  );
29
31
  } catch (error) {
@@ -1,4 +1,5 @@
1
1
  import { getEnvVars, discoverEnvFiles, getSelectedEnvFile, getMainBranch } from '@/lib/db';
2
+ import { AccountSection } from '@/components/settings/AccountSection';
2
3
  import { EnvVarsSection } from '@/components/settings/EnvVarsSection';
3
4
  import { GeneralSection } from '@/components/settings/GeneralSection';
4
5
  import { SettingsLayout } from '@/components/settings/SettingsLayout';
@@ -18,6 +19,7 @@ export default function SettingsPage() {
18
19
  </h1>
19
20
  <SettingsLayout
20
21
  tabs={[
22
+ { id: 'account', label: 'Account', content: <AccountSection /> },
21
23
  { id: 'general', label: 'General', content: <GeneralSection initialMainBranch={mainBranch} /> },
22
24
  { id: 'env-vars', label: 'Environment Variables', content: <EnvVarsSection initialEnvVars={envVars} envFiles={envFiles} selectedFile={selectedFile} /> },
23
25
  ]}
@@ -0,0 +1,11 @@
1
+ 'use client';
2
+
3
+ import { SubscribeContent } from '@/components/SubscribeContent';
4
+
5
+ export default function SubscribePage() {
6
+ return (
7
+ <div className="flex flex-col items-center justify-center min-h-screen bg-white dark:bg-zinc-900 p-8">
8
+ <SubscribeContent />
9
+ </div>
10
+ );
11
+ }
@@ -22,6 +22,28 @@ export default function WelcomePage() {
22
22
  loadRecentProjects();
23
23
  }, []);
24
24
 
25
+ const handleNewProject = async () => {
26
+ setError(null);
27
+
28
+ if (!window.electronAPI?.isElectron) {
29
+ setError('Project creation is only available in the desktop app.');
30
+ return;
31
+ }
32
+
33
+ const result = await window.electronAPI.project.newProject();
34
+
35
+ if (result.canceled) {
36
+ return;
37
+ }
38
+
39
+ if (!result.success) {
40
+ setError(result.error || 'Failed to create project');
41
+ return;
42
+ }
43
+
44
+ window.location.href = '/';
45
+ };
46
+
25
47
  const handleOpenProject = async () => {
26
48
  setError(null);
27
49
 
@@ -76,6 +98,7 @@ export default function WelcomePage() {
76
98
  )}
77
99
  <WelcomeScreen
78
100
  recentProjects={recentProjects}
101
+ onNewProject={handleNewProject}
79
102
  onOpenProject={handleOpenProject}
80
103
  onSelectRecentProject={handleSelectRecentProject}
81
104
  />
@@ -1,15 +1,20 @@
1
1
  'use client';
2
2
 
3
- import { usePathname } from 'next/navigation';
3
+ import { useState, useEffect } from 'react';
4
+ import { usePathname, useRouter } from 'next/navigation';
4
5
  import { ClaudeSessionProvider, useClaudeSession } from '../contexts/ClaudeSessionContext';
5
6
  import { ConnectionStatusProvider } from '../contexts/ConnectionStatusContext';
7
+ import { UsageProvider } from '../contexts/UsageContext';
6
8
  import { ToastProvider } from './Toast';
7
9
  import { MainNav } from './MainNav';
8
10
  import { ClaudePanel } from './ClaudePanel';
9
11
  import type { ReactNode } from 'react';
10
12
 
11
13
  // Pages that should not show the nav header (pre-project screens)
12
- const NO_NAV_PATHS = ['/install-claude', '/welcome'];
14
+ const NO_NAV_PATHS = ['/login', '/subscribe', '/install-claude', '/connect-claude', '/welcome'];
15
+
16
+ // Pages accessible without authentication
17
+ const PUBLIC_PATHS = ['/login', '/subscribe', '/install-claude', '/connect-claude', '/welcome'];
13
18
 
14
19
  interface AppShellProps {
15
20
  projectName: string;
@@ -18,7 +23,35 @@ interface AppShellProps {
18
23
 
19
24
  function AppShellContent({ projectName, children }: AppShellProps) {
20
25
  const pathname = usePathname();
26
+ const router = useRouter();
21
27
  const showNav = !NO_NAV_PATHS.includes(pathname);
28
+ const [authChecked, setAuthChecked] = useState(false);
29
+
30
+ // Auth enforcement gate: redirect unauthenticated users to /login
31
+ useEffect(() => {
32
+ if (PUBLIC_PATHS.includes(pathname)) {
33
+ setAuthChecked(true);
34
+ return;
35
+ }
36
+
37
+ async function checkAuth() {
38
+ if (typeof window !== 'undefined' && window.electronAPI?.isElectron) {
39
+ try {
40
+ const status = await window.electronAPI.auth.getStatus();
41
+ if (!status.authenticated) {
42
+ router.push('/login');
43
+ return;
44
+ }
45
+ } catch {
46
+ // Corrupted auth file, IPC error, etc. — treat as unauthenticated
47
+ router.push('/login');
48
+ return;
49
+ }
50
+ }
51
+ setAuthChecked(true);
52
+ }
53
+ checkAuth();
54
+ }, [pathname, router]);
22
55
 
23
56
  const {
24
57
  claudePanelOpen,
@@ -32,6 +65,7 @@ function AppShellContent({ projectName, children }: AppShellProps) {
32
65
  error,
33
66
  exitCode,
34
67
  canRetry,
68
+ queuedMessage,
35
69
  switchSession,
36
70
  closeSession,
37
71
  createNewSession,
@@ -42,6 +76,11 @@ function AppShellContent({ projectName, children }: AppShellProps) {
42
76
  toggleNarratedMode,
43
77
  } = useClaudeSession();
44
78
 
79
+ // Don't render anything until auth check completes (prevents content flash)
80
+ if (!authChecked) {
81
+ return <div className="h-screen" />;
82
+ }
83
+
45
84
  return (
46
85
  <div className="h-screen flex flex-col overflow-hidden">
47
86
  {showNav && <MainNav projectName={projectName} />}
@@ -58,6 +97,7 @@ function AppShellContent({ projectName, children }: AppShellProps) {
58
97
  error={error}
59
98
  exitCode={exitCode}
60
99
  canRetry={canRetry}
100
+ queuedMessage={queuedMessage}
61
101
  onClose={() => setClaudePanelOpen(false)}
62
102
  onRetry={retry}
63
103
  onSendMessage={sendMessage}
@@ -79,13 +119,15 @@ function AppShellContent({ projectName, children }: AppShellProps) {
79
119
  export function AppShell({ projectName, children }: AppShellProps) {
80
120
  return (
81
121
  <ConnectionStatusProvider>
82
- <ToastProvider>
83
- <ClaudeSessionProvider>
84
- <AppShellContent projectName={projectName}>
85
- {children}
86
- </AppShellContent>
87
- </ClaudeSessionProvider>
88
- </ToastProvider>
122
+ <UsageProvider>
123
+ <ToastProvider>
124
+ <ClaudeSessionProvider>
125
+ <AppShellContent projectName={projectName}>
126
+ {children}
127
+ </AppShellContent>
128
+ </ClaudeSessionProvider>
129
+ </ToastProvider>
130
+ </UsageProvider>
89
131
  </ConnectionStatusProvider>
90
132
  );
91
133
  }