jettypod 4.4.116 → 4.4.120
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env +7 -0
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +124 -48
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +171 -58
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +161 -10
- package/apps/dashboard/app/api/tests/run/stream/route.ts +13 -1
- package/apps/dashboard/app/api/usage/route.ts +17 -0
- package/apps/dashboard/app/api/work/[id]/route.ts +35 -0
- package/apps/dashboard/app/api/work/[id]/status/route.ts +43 -1
- package/apps/dashboard/app/connect-claude/page.tsx +24 -0
- package/apps/dashboard/app/decision/[id]/page.tsx +14 -14
- package/apps/dashboard/app/demo/gates/page.tsx +42 -42
- package/apps/dashboard/app/design-system/page.tsx +868 -0
- package/apps/dashboard/app/globals.css +6 -2
- package/apps/dashboard/app/install-claude/page.tsx +9 -7
- package/apps/dashboard/app/layout.tsx +17 -5
- package/apps/dashboard/app/login/page.tsx +250 -0
- package/apps/dashboard/app/page.tsx +11 -9
- package/apps/dashboard/app/settings/page.tsx +4 -2
- package/apps/dashboard/app/signup/page.tsx +245 -0
- package/apps/dashboard/app/subscribe/page.tsx +11 -0
- package/apps/dashboard/app/welcome/page.tsx +24 -1
- package/apps/dashboard/app/work/[id]/page.tsx +34 -50
- package/apps/dashboard/components/AppShell.tsx +95 -55
- package/apps/dashboard/components/CardMenu.tsx +56 -13
- package/apps/dashboard/components/ClaudePanel.tsx +301 -582
- package/apps/dashboard/components/ClaudePanelInput.tsx +23 -14
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +210 -0
- package/apps/dashboard/components/CopyableId.tsx +3 -3
- package/apps/dashboard/components/DetailReviewActions.tsx +109 -0
- package/apps/dashboard/components/DragContext.tsx +75 -65
- package/apps/dashboard/components/DraggableCard.tsx +6 -46
- package/apps/dashboard/components/DropZone.tsx +2 -2
- package/apps/dashboard/components/EditableDetailDescription.tsx +1 -1
- package/apps/dashboard/components/EditableTitle.tsx +26 -6
- package/apps/dashboard/components/ElapsedTimer.tsx +54 -0
- package/apps/dashboard/components/EpicGroup.tsx +329 -0
- package/apps/dashboard/components/GateCard.tsx +100 -16
- package/apps/dashboard/components/GateChoiceCard.tsx +15 -17
- package/apps/dashboard/components/InstallClaudeScreen.tsx +140 -51
- package/apps/dashboard/components/JettyLoader.tsx +38 -0
- package/apps/dashboard/components/KanbanBoard.tsx +147 -766
- package/apps/dashboard/components/KanbanCard.tsx +506 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +12 -0
- package/apps/dashboard/components/MainNav.tsx +20 -54
- package/apps/dashboard/components/MessageBlock.tsx +391 -0
- package/apps/dashboard/components/ModeStartCard.tsx +15 -15
- package/apps/dashboard/components/OnboardingWelcome.tsx +214 -0
- package/apps/dashboard/components/PlaceholderCard.tsx +11 -21
- package/apps/dashboard/components/ProjectSwitcher.tsx +36 -8
- package/apps/dashboard/components/PrototypeTimeline.tsx +25 -25
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +265 -301
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +97 -74
- package/apps/dashboard/components/ReviewFooter.tsx +141 -0
- package/apps/dashboard/components/SessionList.tsx +19 -18
- package/apps/dashboard/components/SubscribeContent.tsx +206 -0
- package/apps/dashboard/components/TestTree.tsx +15 -14
- package/apps/dashboard/components/TipCard.tsx +177 -0
- package/apps/dashboard/components/Toast.tsx +5 -5
- package/apps/dashboard/components/TypeIcon.tsx +56 -0
- package/apps/dashboard/components/UpgradeBanner.tsx +30 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +61 -62
- package/apps/dashboard/components/WelcomeScreen.tsx +25 -27
- package/apps/dashboard/components/WorkItemHeader.tsx +4 -4
- package/apps/dashboard/components/WorkItemTree.tsx +9 -28
- package/apps/dashboard/components/settings/AccountSection.tsx +169 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +54 -79
- package/apps/dashboard/components/settings/GeneralSection.tsx +26 -31
- package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -4
- package/apps/dashboard/components/ui/Button.tsx +104 -0
- package/apps/dashboard/components/ui/Input.tsx +78 -0
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +408 -105
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -4
- package/apps/dashboard/contexts/UsageContext.tsx +155 -0
- package/apps/dashboard/contexts/usageHelpers.js +9 -0
- package/apps/dashboard/electron/ipc-handlers.js +281 -88
- package/apps/dashboard/electron/main.js +691 -131
- package/apps/dashboard/electron/preload.js +25 -4
- package/apps/dashboard/electron/session-manager.js +163 -0
- package/apps/dashboard/electron-builder.config.js +3 -5
- package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
- package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
- package/apps/dashboard/lib/backlog-parser.ts +50 -0
- package/apps/dashboard/lib/claude-process-manager.ts +50 -11
- package/apps/dashboard/lib/constants.ts +43 -0
- package/apps/dashboard/lib/db-bridge.ts +33 -0
- package/apps/dashboard/lib/db.ts +136 -20
- package/apps/dashboard/lib/kanban-utils.ts +70 -0
- package/apps/dashboard/lib/run-migrations.js +27 -2
- package/apps/dashboard/lib/session-state-machine.ts +3 -0
- package/apps/dashboard/lib/session-stream-manager.ts +144 -38
- package/apps/dashboard/lib/shadows.ts +7 -0
- package/apps/dashboard/lib/tests.ts +3 -1
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next.config.js +35 -14
- package/apps/dashboard/package.json +6 -3
- package/apps/dashboard/public/bug-icon.svg +9 -0
- package/apps/dashboard/public/buoy-icon.svg +9 -0
- package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
- package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
- package/apps/dashboard/public/in-flight-seagull.svg +9 -0
- package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
- package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
- package/apps/dashboard/public/jettypod_logo.png +0 -0
- package/apps/dashboard/public/pier-icon.svg +14 -0
- package/apps/dashboard/public/star-icon.svg +9 -0
- package/apps/dashboard/public/wrench-icon.svg +9 -0
- package/apps/dashboard/scripts/upload-to-r2.js +89 -0
- package/apps/dashboard/scripts/ws-server.js +191 -0
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
- package/apps/update-server/package.json +16 -0
- package/apps/update-server/schema.sql +31 -0
- package/apps/update-server/src/index.ts +1085 -0
- package/apps/update-server/tsconfig.json +16 -0
- package/apps/update-server/wrangler.toml +35 -0
- package/cucumber.js +9 -3
- package/docs/COMMAND_REFERENCE.md +34 -0
- package/hooks/post-checkout +32 -75
- package/hooks/post-merge +111 -10
- package/jest.setup.js +1 -0
- package/jettypod.js +54 -116
- package/lib/chore-taxonomy.js +33 -10
- package/lib/database.js +36 -16
- package/lib/db-watcher.js +1 -1
- package/lib/git-hooks/pre-commit +1 -1
- package/lib/jettypod-backup.js +27 -4
- package/lib/migrations/027-plan-at-creation-column.js +33 -0
- package/lib/migrations/028-ready-for-review-column.js +27 -0
- package/lib/migrations/029-remove-autoincrement.js +307 -0
- package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
- package/lib/migrations/index.js +47 -4
- package/lib/schema.js +13 -6
- package/lib/seed-onboarding.js +101 -69
- package/lib/update-command/index.js +9 -175
- package/lib/work-commands/index.js +129 -16
- package/lib/work-tracking/index.js +86 -46
- package/lib/worktree-diagnostics.js +16 -16
- package/lib/worktree-facade.js +1 -1
- package/lib/worktree-manager.js +8 -8
- package/lib/worktree-reconciler.js +5 -5
- package/package.json +9 -2
- package/scripts/ndjson-to-cucumber-json.js +152 -0
- package/scripts/postinstall.js +25 -0
- package/skills-templates/bug-mode/SKILL.md +39 -28
- package/skills-templates/bug-planning/SKILL.md +25 -29
- package/skills-templates/chore-mode/SKILL.md +131 -68
- package/skills-templates/chore-mode/verification.js +51 -10
- package/skills-templates/chore-planning/SKILL.md +47 -18
- package/skills-templates/epic-planning/SKILL.md +68 -48
- package/skills-templates/external-transition/SKILL.md +47 -47
- package/skills-templates/feature-planning/SKILL.md +83 -73
- package/skills-templates/production-mode/SKILL.md +49 -49
- package/skills-templates/request-routing/SKILL.md +27 -14
- package/skills-templates/simple-improvement/SKILL.md +68 -44
- package/skills-templates/speed-mode/SKILL.md +209 -128
- package/skills-templates/stable-mode/SKILL.md +105 -94
- package/templates/bdd-guidance.md +139 -0
- package/templates/bdd-scaffolding/wait.js +18 -0
- package/templates/bdd-scaffolding/world.js +19 -0
- package/.jettypod-backup/work.db +0 -0
- package/apps/dashboard/app/access-code/page.tsx +0 -110
- package/lib/discovery-checkpoint.js +0 -123
- package/skills-templates/project-discovery/SKILL.md +0 -372
|
@@ -46,6 +46,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|
|
46
46
|
|
|
47
47
|
// Project info
|
|
48
48
|
getProjectName: () => ipcRenderer.invoke('db:getProjectName'),
|
|
49
|
+
|
|
49
50
|
},
|
|
50
51
|
|
|
51
52
|
// Claude subprocess management
|
|
@@ -76,6 +77,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|
|
76
77
|
// Project operations
|
|
77
78
|
project: {
|
|
78
79
|
openDialog: () => ipcRenderer.invoke('dialog:openProject'),
|
|
80
|
+
newProject: () => ipcRenderer.invoke('dialog:newProject'),
|
|
79
81
|
getRecent: () => ipcRenderer.invoke('projects:getRecent'),
|
|
80
82
|
addRecent: (path) => ipcRenderer.invoke('projects:addRecent', path),
|
|
81
83
|
openRecent: (path) => ipcRenderer.invoke('projects:openRecent', path),
|
|
@@ -85,13 +87,32 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|
|
85
87
|
claudeCode: {
|
|
86
88
|
install: () => ipcRenderer.invoke('claudeCode:install'),
|
|
87
89
|
isInstalled: () => ipcRenderer.invoke('claudeCode:isInstalled'),
|
|
90
|
+
isAuthenticated: () => ipcRenderer.invoke('claudeCode:isAuthenticated'),
|
|
91
|
+
login: () => ipcRenderer.invoke('claudeCode:login'),
|
|
88
92
|
update: () => ipcRenderer.invoke('claudeCode:update'),
|
|
89
93
|
},
|
|
90
94
|
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
+
// Subscription gating (legacy)
|
|
96
|
+
subscription: {
|
|
97
|
+
createCheckout: (plan) => ipcRenderer.invoke('subscription:createCheckout', plan),
|
|
98
|
+
activate: (customerId) => ipcRenderer.invoke('subscription:activate', customerId),
|
|
99
|
+
getStatus: () => ipcRenderer.invoke('subscription:getStatus'),
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
// Billing
|
|
103
|
+
billing: {
|
|
104
|
+
openCustomerPortal: () => ipcRenderer.invoke('billing:openCustomerPortal'),
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
// Auth (JWT-based)
|
|
108
|
+
auth: {
|
|
109
|
+
loginWithGoogle: () => ipcRenderer.invoke('auth:loginWithGoogle'),
|
|
110
|
+
saveToken: (token, user) => ipcRenderer.invoke('auth:saveToken', token, user),
|
|
111
|
+
getStatus: () => ipcRenderer.invoke('auth:getStatus'),
|
|
112
|
+
getToken: () => ipcRenderer.invoke('auth:getToken'),
|
|
113
|
+
logout: () => ipcRenderer.invoke('auth:logout'),
|
|
114
|
+
getPostLoginPath: () => ipcRenderer.invoke('auth:getPostLoginPath'),
|
|
115
|
+
hasLoggedInBefore: () => ipcRenderer.invoke('auth:hasLoggedInBefore'),
|
|
95
116
|
},
|
|
96
117
|
|
|
97
118
|
// Shell operations
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { app } = require('electron');
|
|
4
|
+
|
|
5
|
+
const UPDATE_SERVER_URL = 'https://jettypod-update-server.spangbaryn2.workers.dev';
|
|
6
|
+
const HEARTBEAT_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4 hours
|
|
7
|
+
|
|
8
|
+
let heartbeatInterval = null;
|
|
9
|
+
let updaterHeaders = {};
|
|
10
|
+
let log = console.log;
|
|
11
|
+
|
|
12
|
+
function setLogger(logFn) {
|
|
13
|
+
log = logFn;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getAuthPath() {
|
|
17
|
+
return path.join(app.getPath('userData'), 'auth.json');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readAuth() {
|
|
21
|
+
const authPath = getAuthPath();
|
|
22
|
+
if (!fs.existsSync(authPath)) return null;
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(fs.readFileSync(authPath, 'utf-8'));
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function saveAuth(data) {
|
|
31
|
+
const authPath = getAuthPath();
|
|
32
|
+
const dir = path.dirname(authPath);
|
|
33
|
+
if (!fs.existsSync(dir)) {
|
|
34
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
fs.writeFileSync(authPath, JSON.stringify(data, null, 2));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function decodeJWT(token) {
|
|
40
|
+
const parts = token.split('.');
|
|
41
|
+
if (parts.length !== 3) return null;
|
|
42
|
+
try {
|
|
43
|
+
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
44
|
+
return JSON.parse(Buffer.from(payload, 'base64').toString('utf-8'));
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function updateAutoUpdaterHeaders() {
|
|
51
|
+
const auth = readAuth();
|
|
52
|
+
if (auth && auth.token) {
|
|
53
|
+
updaterHeaders = { 'Authorization': `Bearer ${auth.token}` };
|
|
54
|
+
} else {
|
|
55
|
+
updaterHeaders = {};
|
|
56
|
+
}
|
|
57
|
+
return updaterHeaders;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function heartbeatWithRetry(maxRetries = 3) {
|
|
61
|
+
const auth = readAuth();
|
|
62
|
+
if (!auth || !auth.token) {
|
|
63
|
+
log('[Session] No auth token for heartbeat');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
log('[Session] Heartbeat: checking /auth/me...');
|
|
68
|
+
|
|
69
|
+
let lastError;
|
|
70
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
71
|
+
if (attempt > 0) {
|
|
72
|
+
const delay = Math.min(1000 * Math.pow(2, attempt), 30000);
|
|
73
|
+
log(`[Session] Heartbeat retry ${attempt}/${maxRetries - 1} in ${delay}ms...`);
|
|
74
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const response = await fetch(`${UPDATE_SERVER_URL}/auth/me`, {
|
|
79
|
+
headers: { 'Authorization': `Bearer ${auth.token}` },
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
log(`[Session] Heartbeat failed: ${response.status}`);
|
|
84
|
+
if (response.status >= 500) {
|
|
85
|
+
lastError = new Error(`Server error: ${response.status}`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const data = await response.json();
|
|
92
|
+
|
|
93
|
+
// If server returned a new token (plan changed), save it
|
|
94
|
+
if (data.token) {
|
|
95
|
+
const payload = decodeJWT(data.token);
|
|
96
|
+
const user = payload
|
|
97
|
+
? { id: payload.sub, email: payload.email, plan: payload.plan }
|
|
98
|
+
: auth.user;
|
|
99
|
+
saveAuth({ token: data.token, user, savedAt: new Date().toISOString() });
|
|
100
|
+
log('[Session] Token refreshed from server');
|
|
101
|
+
} else if (data.user && data.user.plan !== auth.user?.plan) {
|
|
102
|
+
// Plan changed but no new token — update user info
|
|
103
|
+
saveAuth({ ...auth, user: data.user, savedAt: new Date().toISOString() });
|
|
104
|
+
log(`[Session] Plan updated: ${auth.user?.plan} → ${data.user.plan}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
updateAutoUpdaterHeaders();
|
|
108
|
+
return;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
lastError = error;
|
|
111
|
+
log(`[Session] Heartbeat error: ${error.message}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
log(`[Session] Heartbeat failed after ${maxRetries} attempts: ${lastError?.message}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function heartbeat() {
|
|
119
|
+
return heartbeatWithRetry();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function start() {
|
|
123
|
+
log('[Session] Starting session manager...');
|
|
124
|
+
updateAutoUpdaterHeaders();
|
|
125
|
+
|
|
126
|
+
// Run first heartbeat after a short delay (don't block startup)
|
|
127
|
+
setTimeout(heartbeat, 5000);
|
|
128
|
+
|
|
129
|
+
// Schedule periodic heartbeats
|
|
130
|
+
heartbeatInterval = setInterval(heartbeat, HEARTBEAT_INTERVAL_MS);
|
|
131
|
+
|
|
132
|
+
log('[Session] Session manager started (heartbeat every 4 hours)');
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function stop() {
|
|
137
|
+
if (heartbeatInterval) {
|
|
138
|
+
clearInterval(heartbeatInterval);
|
|
139
|
+
heartbeatInterval = null;
|
|
140
|
+
}
|
|
141
|
+
log('[Session] Session manager stopped');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isRunning() {
|
|
145
|
+
return heartbeatInterval !== null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function getUpdaterHeaders() {
|
|
149
|
+
return updaterHeaders;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = {
|
|
153
|
+
start,
|
|
154
|
+
stop,
|
|
155
|
+
heartbeat,
|
|
156
|
+
readAuth,
|
|
157
|
+
saveAuth,
|
|
158
|
+
updateAutoUpdaterHeaders,
|
|
159
|
+
isRunning,
|
|
160
|
+
getUpdaterHeaders,
|
|
161
|
+
setLogger,
|
|
162
|
+
getAuthPath,
|
|
163
|
+
};
|
|
@@ -314,12 +314,10 @@ module.exports = {
|
|
|
314
314
|
// Rebuild native modules
|
|
315
315
|
npmRebuild: true,
|
|
316
316
|
|
|
317
|
-
// Auto-update configuration -
|
|
317
|
+
// Auto-update configuration - generic provider pointing to update server
|
|
318
318
|
publish: {
|
|
319
|
-
provider: '
|
|
320
|
-
|
|
321
|
-
repo: 'jettypod-source',
|
|
322
|
-
releaseType: 'release',
|
|
319
|
+
provider: 'generic',
|
|
320
|
+
url: 'https://jettypod-update-server.spangbaryn2.workers.dev/updates',
|
|
323
321
|
},
|
|
324
322
|
|
|
325
323
|
// Hooks for native module handling
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useState, useCallback, useRef } from 'react';
|
|
2
|
+
import type { KanbanData } from '@/lib/kanban-utils';
|
|
3
|
+
|
|
4
|
+
export function useKanbanAnimation() {
|
|
5
|
+
const [externalAnimatingItemId, setExternalAnimatingItemId] = useState<number | null>(null);
|
|
6
|
+
const pendingDataRef = useRef<KanbanData | null>(null);
|
|
7
|
+
|
|
8
|
+
// Track items animated internally so WebSocket handler skips them
|
|
9
|
+
const lastInternallyAnimatedIdRef = useRef<number | null>(null);
|
|
10
|
+
|
|
11
|
+
const handleExternalAnimationComplete = useCallback(() => {
|
|
12
|
+
setExternalAnimatingItemId(null);
|
|
13
|
+
if (pendingDataRef.current) {
|
|
14
|
+
// Return the pending data for the caller to apply
|
|
15
|
+
const pending = pendingDataRef.current;
|
|
16
|
+
pendingDataRef.current = null;
|
|
17
|
+
return pending;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}, []);
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
externalAnimatingItemId,
|
|
24
|
+
setExternalAnimatingItemId,
|
|
25
|
+
pendingDataRef,
|
|
26
|
+
lastInternallyAnimatedIdRef,
|
|
27
|
+
handleExternalAnimationComplete,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react';
|
|
2
|
+
import { UndoStack, type UndoAction } from '@/lib/undoStack';
|
|
3
|
+
|
|
4
|
+
// Helper to format status for display
|
|
5
|
+
function formatStatus(status: string): string {
|
|
6
|
+
switch (status) {
|
|
7
|
+
case 'in_progress': return 'In Flight';
|
|
8
|
+
case 'backlog': return 'Backlog';
|
|
9
|
+
case 'done': return 'Done';
|
|
10
|
+
default: return status;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface UseKanbanUndoOptions {
|
|
15
|
+
onStatusChange: (id: number, newStatus: string, skipUndo?: boolean) => Promise<{ success: boolean; notFound?: boolean }>;
|
|
16
|
+
showToast: (message: string, type?: 'error' | 'info' | 'success') => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useKanbanUndo({ onStatusChange, showToast }: UseKanbanUndoOptions) {
|
|
20
|
+
const [undoStack] = useState(() => new UndoStack());
|
|
21
|
+
const [undoRedoVersion, setUndoRedoVersion] = useState(0);
|
|
22
|
+
|
|
23
|
+
const pushAction = useCallback((action: UndoAction) => {
|
|
24
|
+
undoStack.push(action);
|
|
25
|
+
setUndoRedoVersion(v => v + 1);
|
|
26
|
+
}, [undoStack]);
|
|
27
|
+
|
|
28
|
+
const handleUndo = useCallback(async (): Promise<UndoAction | null> => {
|
|
29
|
+
const action = undoStack.undo();
|
|
30
|
+
if (!action) return null;
|
|
31
|
+
|
|
32
|
+
const result = await onStatusChange(action.itemId, action.before, true);
|
|
33
|
+
setUndoRedoVersion(v => v + 1);
|
|
34
|
+
|
|
35
|
+
if (result.notFound) {
|
|
36
|
+
showToast(`Item no longer exists: "${action.itemTitle}" was deleted`, 'error');
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!result.success) {
|
|
41
|
+
undoStack.push({
|
|
42
|
+
type: action.type,
|
|
43
|
+
itemId: action.itemId,
|
|
44
|
+
itemTitle: action.itemTitle,
|
|
45
|
+
before: action.after,
|
|
46
|
+
after: action.before,
|
|
47
|
+
});
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
showToast(`Undone: "${action.itemTitle}" moved back to ${formatStatus(action.before)}`);
|
|
52
|
+
return action;
|
|
53
|
+
}, [undoStack, onStatusChange, showToast]);
|
|
54
|
+
|
|
55
|
+
const handleRedo = useCallback(async (): Promise<UndoAction | null> => {
|
|
56
|
+
const action = undoStack.redo();
|
|
57
|
+
if (!action) return null;
|
|
58
|
+
|
|
59
|
+
const result = await onStatusChange(action.itemId, action.after, true);
|
|
60
|
+
setUndoRedoVersion(v => v + 1);
|
|
61
|
+
|
|
62
|
+
if (result.notFound) {
|
|
63
|
+
showToast(`Item no longer exists: "${action.itemTitle}" was deleted`, 'error');
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!result.success) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
showToast(`Redone: "${action.itemTitle}" moved to ${formatStatus(action.after)}`);
|
|
72
|
+
return action;
|
|
73
|
+
}, [undoStack, onStatusChange, showToast]);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
undoStack,
|
|
77
|
+
pushAction,
|
|
78
|
+
handleUndo,
|
|
79
|
+
handleRedo,
|
|
80
|
+
canUndo: undoStack.canUndo(),
|
|
81
|
+
canRedo: undoStack.canRedo(),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Local keyword parser for fast backlog work item creation
|
|
2
|
+
// Avoids spawning Claude CLI for obvious inputs
|
|
3
|
+
|
|
4
|
+
export interface ParseResult {
|
|
5
|
+
type: 'epic' | 'feature' | 'chore' | 'bug';
|
|
6
|
+
title: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const FILLER_WORDS = /\b(um|uh|like|basically|we need|we should|can you|i want|i need|let's|or something|you know|kind of|sort of|maybe|probably|just|actually)\b/gi;
|
|
10
|
+
|
|
11
|
+
const TYPE_PATTERNS: { type: ParseResult['type']; keywords: RegExp }[] = [
|
|
12
|
+
{ type: 'bug', keywords: /\b(bug|broken|doesn't work|not working|crash|error|regression)\b/i },
|
|
13
|
+
{ type: 'epic', keywords: /\b(system|full|complete|epic|initiative|project|roadmap)\b/i },
|
|
14
|
+
{ type: 'chore', keywords: /\b(upgrade|refactor|migrate|update|version|infrastructure|cleanup|clean up|tooling|ci|cd|pipeline|lint|config)\b/i },
|
|
15
|
+
// feature is the default — no pattern needed
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export function parseBacklogInput(input: string): ParseResult | null {
|
|
19
|
+
const trimmed = input.trim();
|
|
20
|
+
if (!trimmed || trimmed.length < 3) return null;
|
|
21
|
+
|
|
22
|
+
// Clean up filler words
|
|
23
|
+
let cleaned = trimmed.replace(FILLER_WORDS, ' ').replace(/\s+/g, ' ').trim();
|
|
24
|
+
if (!cleaned || cleaned.length < 3) return null;
|
|
25
|
+
|
|
26
|
+
// Detect type from keywords
|
|
27
|
+
let type: ParseResult['type'] = 'feature';
|
|
28
|
+
for (const pattern of TYPE_PATTERNS) {
|
|
29
|
+
if (pattern.keywords.test(cleaned)) {
|
|
30
|
+
type = pattern.type;
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Check for comma-separated list → epic
|
|
36
|
+
if (cleaned.includes(',') && cleaned.split(',').filter(s => s.trim()).length >= 3) {
|
|
37
|
+
type = 'epic';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Clean title: remove trailing period, capitalize first letter
|
|
41
|
+
let title = cleaned.replace(/\.$/, '').trim();
|
|
42
|
+
title = title.charAt(0).toUpperCase() + title.slice(1);
|
|
43
|
+
|
|
44
|
+
// Truncate to 60 chars
|
|
45
|
+
if (title.length > 60) {
|
|
46
|
+
title = title.substring(0, 57) + '...';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { type, title };
|
|
50
|
+
}
|
|
@@ -27,8 +27,10 @@ const pinnedSessions = new Set<string>();
|
|
|
27
27
|
// Maximum concurrent Claude processes allowed
|
|
28
28
|
const MAX_PROCESSES = 8;
|
|
29
29
|
|
|
30
|
-
// Timeout for idle processes (
|
|
31
|
-
|
|
30
|
+
// Timeout for idle processes (2 hours, only applies to unpinned sessions).
|
|
31
|
+
// Longer timeout reduces process respawn frequency, avoiding the cost of
|
|
32
|
+
// context restoration (dumping full conversation history into a single message).
|
|
33
|
+
const IDLE_TIMEOUT_MS = 2 * 60 * 60 * 1000;
|
|
32
34
|
|
|
33
35
|
// ============================================================================
|
|
34
36
|
// Auto Gate Emission
|
|
@@ -200,7 +202,8 @@ function cleanupGateState(sessionId: string): void {
|
|
|
200
202
|
export function getOrCreateProcess(
|
|
201
203
|
sessionId: string,
|
|
202
204
|
cwd: string,
|
|
203
|
-
settingsPath?: string
|
|
205
|
+
settingsPath?: string,
|
|
206
|
+
options?: { model?: string }
|
|
204
207
|
): { emitter: EventEmitter; isNew: boolean } | { error: string } {
|
|
205
208
|
const existing = processes.get(sessionId);
|
|
206
209
|
|
|
@@ -236,18 +239,33 @@ export function getOrCreateProcess(
|
|
|
236
239
|
args.push('--settings', settingsPath);
|
|
237
240
|
}
|
|
238
241
|
|
|
242
|
+
if (options?.model) {
|
|
243
|
+
args.push('--model', options.model);
|
|
244
|
+
}
|
|
245
|
+
|
|
239
246
|
const claudeProcess = spawn('claude', args, {
|
|
240
247
|
cwd,
|
|
241
248
|
env: { ...process.env, JETTYPOD_SESSION_ID: sessionId },
|
|
242
249
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
243
250
|
});
|
|
244
251
|
|
|
245
|
-
// Handle stdout - stream JSON responses
|
|
252
|
+
// Handle stdout - stream JSON responses.
|
|
253
|
+
// Buffer incomplete lines across data events. Node.js delivers arbitrary
|
|
254
|
+
// chunks on pipes, so a single JSON line can be split across two events.
|
|
255
|
+
// Without buffering, split lines fail JSON.parse and critical events like
|
|
256
|
+
// `result` are lost — causing the chat panel to hang forever.
|
|
257
|
+
let stdoutBuffer = '';
|
|
246
258
|
claudeProcess.stdout.on('data', (data: Buffer) => {
|
|
247
|
-
|
|
259
|
+
stdoutBuffer += data.toString();
|
|
260
|
+
const lines = stdoutBuffer.split('\n');
|
|
261
|
+
// Last element is either '' (chunk ended with \n) or an incomplete line
|
|
262
|
+
stdoutBuffer = lines.pop() || '';
|
|
263
|
+
|
|
248
264
|
for (const line of lines) {
|
|
265
|
+
const trimmed = line.trim();
|
|
266
|
+
if (!trimmed) continue;
|
|
249
267
|
try {
|
|
250
|
-
const parsed = JSON.parse(
|
|
268
|
+
const parsed = JSON.parse(trimmed);
|
|
251
269
|
|
|
252
270
|
// Auto-detect workflow events and emit synthetic gate events
|
|
253
271
|
const syntheticGates = detectGates(sessionId, parsed);
|
|
@@ -258,7 +276,7 @@ export function getOrCreateProcess(
|
|
|
258
276
|
emitter.emit('data', parsed);
|
|
259
277
|
} catch {
|
|
260
278
|
// Non-JSON line, emit as raw text
|
|
261
|
-
emitter.emit('data', { type: 'text', content:
|
|
279
|
+
emitter.emit('data', { type: 'text', content: trimmed });
|
|
262
280
|
}
|
|
263
281
|
}
|
|
264
282
|
});
|
|
@@ -299,7 +317,7 @@ export function getOrCreateProcess(
|
|
|
299
317
|
* Send a user message to a session's Claude process via stdin.
|
|
300
318
|
* The message is formatted as stream-json.
|
|
301
319
|
*/
|
|
302
|
-
export function sendMessage(sessionId: string, message: string): boolean {
|
|
320
|
+
export function sendMessage(sessionId: string, message: string, images?: Array<{ type: string; data: string }>): boolean {
|
|
303
321
|
const proc = processes.get(sessionId);
|
|
304
322
|
|
|
305
323
|
if (!proc || !isProcessHealthy(proc)) {
|
|
@@ -312,12 +330,33 @@ export function sendMessage(sessionId: string, message: string): boolean {
|
|
|
312
330
|
|
|
313
331
|
proc.lastActivityAt = Date.now();
|
|
314
332
|
|
|
333
|
+
// Build content: plain string when no images, content blocks array when images present
|
|
334
|
+
let content: string | Array<Record<string, unknown>> = message;
|
|
335
|
+
if (images && images.length > 0) {
|
|
336
|
+
const contentBlocks: Array<Record<string, unknown>> = [];
|
|
337
|
+
for (const img of images) {
|
|
338
|
+
// Frontend sends dataUrl like "data:image/png;base64,iVBOR..."
|
|
339
|
+
// Strip the data URL prefix to get raw base64
|
|
340
|
+
const base64Data = img.data.replace(/^data:[^;]+;base64,/, '');
|
|
341
|
+
contentBlocks.push({
|
|
342
|
+
type: 'image',
|
|
343
|
+
source: {
|
|
344
|
+
type: 'base64',
|
|
345
|
+
media_type: img.type,
|
|
346
|
+
data: base64Data,
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
contentBlocks.push({ type: 'text', text: message });
|
|
351
|
+
content = contentBlocks;
|
|
352
|
+
}
|
|
353
|
+
|
|
315
354
|
// Format as stream-json message
|
|
316
355
|
const jsonMessage = JSON.stringify({
|
|
317
356
|
type: 'user',
|
|
318
357
|
message: {
|
|
319
358
|
role: 'user',
|
|
320
|
-
content
|
|
359
|
+
content,
|
|
321
360
|
},
|
|
322
361
|
});
|
|
323
362
|
|
|
@@ -484,7 +523,7 @@ export function cleanupIdleProcesses(): number {
|
|
|
484
523
|
return cleaned;
|
|
485
524
|
}
|
|
486
525
|
|
|
487
|
-
// Start periodic cleanup (every
|
|
526
|
+
// Start periodic cleanup (every 15 minutes)
|
|
488
527
|
setInterval(() => {
|
|
489
528
|
cleanupIdleProcesses();
|
|
490
|
-
},
|
|
529
|
+
}, 15 * 60 * 1000);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Shared label/icon mappings used across KanbanBoard, WorkItemTree, and detail pages
|
|
2
|
+
|
|
3
|
+
export const TYPE_ICONS: Record<string, string> = {
|
|
4
|
+
epic: '🎯',
|
|
5
|
+
feature: '✨',
|
|
6
|
+
chore: '🔧',
|
|
7
|
+
bug: '🐛',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const TYPE_LABELS: Record<string, { icon: string; label: string }> = {
|
|
11
|
+
epic: { icon: '🎯', label: 'Epic' },
|
|
12
|
+
feature: { icon: '✨', label: 'Feature' },
|
|
13
|
+
chore: { icon: '🔧', label: 'Chore' },
|
|
14
|
+
bug: { icon: '🐛', label: 'Bug' },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
|
18
|
+
backlog: { label: 'Backlog', color: 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300' },
|
|
19
|
+
todo: { label: 'Todo', color: 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300' },
|
|
20
|
+
in_progress: { label: 'In Progress', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300' },
|
|
21
|
+
done: { label: 'Done', color: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300' },
|
|
22
|
+
cancelled: { label: 'Cancelled', color: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300' },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const STATUS_COLORS: Record<string, string> = {
|
|
26
|
+
backlog: 'text-zinc-500',
|
|
27
|
+
todo: 'text-zinc-500',
|
|
28
|
+
in_progress: 'text-blue-600 dark:text-blue-400',
|
|
29
|
+
done: 'text-green-600 dark:text-green-400',
|
|
30
|
+
cancelled: 'text-red-500',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const MODE_LABELS: Record<string, { label: string; color: string }> = {
|
|
34
|
+
speed: { label: 'speed', color: 'bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-300' },
|
|
35
|
+
stable: { label: 'stable', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300' },
|
|
36
|
+
production: { label: 'prod', color: 'bg-purple-100 text-purple-800 dark:bg-purple-900/50 dark:text-purple-300' },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const MODE_LABELS_FULL: Record<string, { label: string; color: string }> = {
|
|
40
|
+
speed: { label: 'Speed Mode', color: 'bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-300' },
|
|
41
|
+
stable: { label: 'Stable Mode', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300' },
|
|
42
|
+
production: { label: 'Production Mode', color: 'bg-purple-100 text-purple-800 dark:bg-purple-900/50 dark:text-purple-300' },
|
|
43
|
+
};
|
|
@@ -19,6 +19,12 @@ interface ElectronAPI {
|
|
|
19
19
|
error?: string;
|
|
20
20
|
path?: string;
|
|
21
21
|
}>;
|
|
22
|
+
newProject: () => Promise<{
|
|
23
|
+
success: boolean;
|
|
24
|
+
canceled?: boolean;
|
|
25
|
+
error?: string;
|
|
26
|
+
path?: string;
|
|
27
|
+
}>;
|
|
22
28
|
getRecent: () => Promise<RecentProject[]>;
|
|
23
29
|
openRecent: (path: string) => Promise<{
|
|
24
30
|
success: boolean;
|
|
@@ -32,6 +38,11 @@ interface ElectronAPI {
|
|
|
32
38
|
error?: string;
|
|
33
39
|
}>;
|
|
34
40
|
isInstalled: () => Promise<boolean>;
|
|
41
|
+
isAuthenticated: () => Promise<boolean>;
|
|
42
|
+
login: () => Promise<{
|
|
43
|
+
success: boolean;
|
|
44
|
+
error?: string;
|
|
45
|
+
}>;
|
|
35
46
|
update: () => Promise<{
|
|
36
47
|
success: boolean;
|
|
37
48
|
error?: string;
|
|
@@ -48,6 +59,26 @@ interface ElectronAPI {
|
|
|
48
59
|
activatedAt?: string;
|
|
49
60
|
}>;
|
|
50
61
|
};
|
|
62
|
+
auth: {
|
|
63
|
+
loginWithGoogle: () => Promise<void>;
|
|
64
|
+
saveToken: (token: string, user: { email: string; plan?: string }) => Promise<void>;
|
|
65
|
+
getStatus: () => Promise<{
|
|
66
|
+
authenticated: boolean;
|
|
67
|
+
token?: string;
|
|
68
|
+
user?: { email: string; plan?: string };
|
|
69
|
+
}>;
|
|
70
|
+
getToken: () => Promise<string | null>;
|
|
71
|
+
logout: () => Promise<void>;
|
|
72
|
+
getPostLoginPath: () => Promise<string | null>;
|
|
73
|
+
};
|
|
74
|
+
subscription: {
|
|
75
|
+
createCheckout: (plan: string) => Promise<{ success: boolean; error?: string }>;
|
|
76
|
+
activate: (customerId: string) => Promise<{ success: boolean; error?: string }>;
|
|
77
|
+
getStatus: () => Promise<{ active: boolean; customerId?: string; activatedAt?: string }>;
|
|
78
|
+
};
|
|
79
|
+
billing: {
|
|
80
|
+
openCustomerPortal: () => Promise<{ success: boolean; error?: string }>;
|
|
81
|
+
};
|
|
51
82
|
shell: {
|
|
52
83
|
openPath: (filePath: string) => Promise<string>;
|
|
53
84
|
};
|
|
@@ -123,6 +154,7 @@ export type {
|
|
|
123
154
|
SessionWithFeature,
|
|
124
155
|
SessionStatus,
|
|
125
156
|
EnvVar,
|
|
157
|
+
WeeklyUsage,
|
|
126
158
|
} from './db';
|
|
127
159
|
|
|
128
160
|
// ==================== Kanban ====================
|
|
@@ -248,3 +280,4 @@ export function deleteEnvVar(name: string): void {
|
|
|
248
280
|
export function getProjectName(): string {
|
|
249
281
|
return directDb.getProjectName();
|
|
250
282
|
}
|
|
283
|
+
|