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.
- package/.env +7 -0
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +25 -9
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +7 -3
- 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/connect-claude/page.tsx +24 -0
- package/apps/dashboard/app/install-claude/page.tsx +8 -6
- package/apps/dashboard/app/login/page.tsx +229 -0
- package/apps/dashboard/app/page.tsx +5 -3
- package/apps/dashboard/app/settings/page.tsx +2 -0
- package/apps/dashboard/app/subscribe/page.tsx +11 -0
- package/apps/dashboard/app/welcome/page.tsx +23 -0
- package/apps/dashboard/components/AppShell.tsx +51 -9
- package/apps/dashboard/components/CardMenu.tsx +14 -5
- package/apps/dashboard/components/ClaudePanel.tsx +65 -9
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +223 -0
- package/apps/dashboard/components/DragContext.tsx +73 -64
- package/apps/dashboard/components/DraggableCard.tsx +6 -46
- package/apps/dashboard/components/GateCard.tsx +21 -0
- package/apps/dashboard/components/InstallClaudeScreen.tsx +132 -30
- package/apps/dashboard/components/KanbanBoard.tsx +173 -56
- package/apps/dashboard/components/PlaceholderCard.tsx +9 -19
- package/apps/dashboard/components/ProjectSwitcher.tsx +28 -0
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +34 -3
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +30 -2
- package/apps/dashboard/components/SubscribeContent.tsx +191 -0
- package/apps/dashboard/components/TipCard.tsx +176 -0
- package/apps/dashboard/components/UpgradeBanner.tsx +29 -0
- package/apps/dashboard/components/WelcomeScreen.tsx +14 -4
- package/apps/dashboard/components/settings/AccountSection.tsx +163 -0
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +292 -29
- package/apps/dashboard/contexts/UsageContext.tsx +131 -0
- package/apps/dashboard/contexts/usageHelpers.js +9 -0
- package/apps/dashboard/electron/ipc-handlers.js +220 -114
- package/apps/dashboard/electron/main.js +415 -37
- package/apps/dashboard/electron/preload.js +23 -4
- package/apps/dashboard/electron/session-manager.js +141 -0
- package/apps/dashboard/electron-builder.config.js +3 -5
- package/apps/dashboard/lib/claude-process-manager.ts +6 -4
- package/apps/dashboard/lib/db-bridge.ts +32 -0
- package/apps/dashboard/lib/db.ts +159 -13
- package/apps/dashboard/lib/session-state-machine.ts +3 -0
- package/apps/dashboard/lib/session-stream-manager.ts +76 -13
- package/apps/dashboard/lib/tests.ts +3 -1
- package/apps/dashboard/next.config.js +19 -14
- package/apps/dashboard/package.json +3 -1
- package/apps/dashboard/scripts/upload-to-r2.js +89 -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 +1074 -0
- package/apps/update-server/tsconfig.json +16 -0
- package/apps/update-server/wrangler.toml +35 -0
- package/docs/bdd-guidance.md +390 -0
- package/jettypod.js +5 -4
- package/lib/migrations/027-plan-at-creation-column.js +31 -0
- package/lib/migrations/028-ready-for-review-column.js +27 -0
- package/lib/schema.js +3 -1
- package/lib/seed-onboarding.js +100 -68
- package/lib/work-commands/index.js +43 -13
- package/lib/work-tracking/index.js +46 -27
- package/package.json +1 -1
- package/skills-templates/bug-mode/SKILL.md +5 -11
- package/skills-templates/request-routing/SKILL.md +24 -11
- package/skills-templates/simple-improvement/SKILL.md +35 -19
- package/skills-templates/stable-mode/SKILL.md +5 -6
- 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
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
|
-
|
|
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
|
-
//
|
|
155
|
-
const
|
|
156
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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,
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
31
|
+
// Show success animation
|
|
32
|
+
setIsSuccess(true);
|
|
33
33
|
|
|
34
|
-
//
|
|
35
|
-
|
|
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
|
-
|
|
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="
|
|
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 {
|
|
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
|
-
<
|
|
83
|
-
<
|
|
84
|
-
<
|
|
85
|
-
{
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
}
|