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.
Files changed (162) hide show
  1. package/.env +7 -0
  2. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +124 -48
  3. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +171 -58
  4. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +161 -10
  5. package/apps/dashboard/app/api/tests/run/stream/route.ts +13 -1
  6. package/apps/dashboard/app/api/usage/route.ts +17 -0
  7. package/apps/dashboard/app/api/work/[id]/route.ts +35 -0
  8. package/apps/dashboard/app/api/work/[id]/status/route.ts +43 -1
  9. package/apps/dashboard/app/connect-claude/page.tsx +24 -0
  10. package/apps/dashboard/app/decision/[id]/page.tsx +14 -14
  11. package/apps/dashboard/app/demo/gates/page.tsx +42 -42
  12. package/apps/dashboard/app/design-system/page.tsx +868 -0
  13. package/apps/dashboard/app/globals.css +6 -2
  14. package/apps/dashboard/app/install-claude/page.tsx +9 -7
  15. package/apps/dashboard/app/layout.tsx +17 -5
  16. package/apps/dashboard/app/login/page.tsx +250 -0
  17. package/apps/dashboard/app/page.tsx +11 -9
  18. package/apps/dashboard/app/settings/page.tsx +4 -2
  19. package/apps/dashboard/app/signup/page.tsx +245 -0
  20. package/apps/dashboard/app/subscribe/page.tsx +11 -0
  21. package/apps/dashboard/app/welcome/page.tsx +24 -1
  22. package/apps/dashboard/app/work/[id]/page.tsx +34 -50
  23. package/apps/dashboard/components/AppShell.tsx +95 -55
  24. package/apps/dashboard/components/CardMenu.tsx +56 -13
  25. package/apps/dashboard/components/ClaudePanel.tsx +301 -582
  26. package/apps/dashboard/components/ClaudePanelInput.tsx +23 -14
  27. package/apps/dashboard/components/ConnectClaudeScreen.tsx +210 -0
  28. package/apps/dashboard/components/CopyableId.tsx +3 -3
  29. package/apps/dashboard/components/DetailReviewActions.tsx +109 -0
  30. package/apps/dashboard/components/DragContext.tsx +75 -65
  31. package/apps/dashboard/components/DraggableCard.tsx +6 -46
  32. package/apps/dashboard/components/DropZone.tsx +2 -2
  33. package/apps/dashboard/components/EditableDetailDescription.tsx +1 -1
  34. package/apps/dashboard/components/EditableTitle.tsx +26 -6
  35. package/apps/dashboard/components/ElapsedTimer.tsx +54 -0
  36. package/apps/dashboard/components/EpicGroup.tsx +329 -0
  37. package/apps/dashboard/components/GateCard.tsx +100 -16
  38. package/apps/dashboard/components/GateChoiceCard.tsx +15 -17
  39. package/apps/dashboard/components/InstallClaudeScreen.tsx +140 -51
  40. package/apps/dashboard/components/JettyLoader.tsx +38 -0
  41. package/apps/dashboard/components/KanbanBoard.tsx +147 -766
  42. package/apps/dashboard/components/KanbanCard.tsx +506 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +12 -0
  44. package/apps/dashboard/components/MainNav.tsx +20 -54
  45. package/apps/dashboard/components/MessageBlock.tsx +391 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -15
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +214 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +11 -21
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +36 -8
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +25 -25
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +265 -301
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +97 -74
  53. package/apps/dashboard/components/ReviewFooter.tsx +141 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -18
  55. package/apps/dashboard/components/SubscribeContent.tsx +206 -0
  56. package/apps/dashboard/components/TestTree.tsx +15 -14
  57. package/apps/dashboard/components/TipCard.tsx +177 -0
  58. package/apps/dashboard/components/Toast.tsx +5 -5
  59. package/apps/dashboard/components/TypeIcon.tsx +56 -0
  60. package/apps/dashboard/components/UpgradeBanner.tsx +30 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +61 -62
  62. package/apps/dashboard/components/WelcomeScreen.tsx +25 -27
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -4
  64. package/apps/dashboard/components/WorkItemTree.tsx +9 -28
  65. package/apps/dashboard/components/settings/AccountSection.tsx +169 -0
  66. package/apps/dashboard/components/settings/EnvVarsSection.tsx +54 -79
  67. package/apps/dashboard/components/settings/GeneralSection.tsx +26 -31
  68. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -4
  69. package/apps/dashboard/components/ui/Button.tsx +104 -0
  70. package/apps/dashboard/components/ui/Input.tsx +78 -0
  71. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +408 -105
  72. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -4
  73. package/apps/dashboard/contexts/UsageContext.tsx +155 -0
  74. package/apps/dashboard/contexts/usageHelpers.js +9 -0
  75. package/apps/dashboard/electron/ipc-handlers.js +281 -88
  76. package/apps/dashboard/electron/main.js +691 -131
  77. package/apps/dashboard/electron/preload.js +25 -4
  78. package/apps/dashboard/electron/session-manager.js +163 -0
  79. package/apps/dashboard/electron-builder.config.js +3 -5
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/lib/backlog-parser.ts +50 -0
  83. package/apps/dashboard/lib/claude-process-manager.ts +50 -11
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/db-bridge.ts +33 -0
  86. package/apps/dashboard/lib/db.ts +136 -20
  87. package/apps/dashboard/lib/kanban-utils.ts +70 -0
  88. package/apps/dashboard/lib/run-migrations.js +27 -2
  89. package/apps/dashboard/lib/session-state-machine.ts +3 -0
  90. package/apps/dashboard/lib/session-stream-manager.ts +144 -38
  91. package/apps/dashboard/lib/shadows.ts +7 -0
  92. package/apps/dashboard/lib/tests.ts +3 -1
  93. package/apps/dashboard/lib/utils.ts +6 -0
  94. package/apps/dashboard/next.config.js +35 -14
  95. package/apps/dashboard/package.json +6 -3
  96. package/apps/dashboard/public/bug-icon.svg +9 -0
  97. package/apps/dashboard/public/buoy-icon.svg +9 -0
  98. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  99. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  100. package/apps/dashboard/public/in-flight-seagull.svg +9 -0
  101. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  102. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  103. package/apps/dashboard/public/jettypod_logo.png +0 -0
  104. package/apps/dashboard/public/pier-icon.svg +14 -0
  105. package/apps/dashboard/public/star-icon.svg +9 -0
  106. package/apps/dashboard/public/wrench-icon.svg +9 -0
  107. package/apps/dashboard/scripts/upload-to-r2.js +89 -0
  108. package/apps/dashboard/scripts/ws-server.js +191 -0
  109. package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
  110. package/apps/update-server/package.json +16 -0
  111. package/apps/update-server/schema.sql +31 -0
  112. package/apps/update-server/src/index.ts +1085 -0
  113. package/apps/update-server/tsconfig.json +16 -0
  114. package/apps/update-server/wrangler.toml +35 -0
  115. package/cucumber.js +9 -3
  116. package/docs/COMMAND_REFERENCE.md +34 -0
  117. package/hooks/post-checkout +32 -75
  118. package/hooks/post-merge +111 -10
  119. package/jest.setup.js +1 -0
  120. package/jettypod.js +54 -116
  121. package/lib/chore-taxonomy.js +33 -10
  122. package/lib/database.js +36 -16
  123. package/lib/db-watcher.js +1 -1
  124. package/lib/git-hooks/pre-commit +1 -1
  125. package/lib/jettypod-backup.js +27 -4
  126. package/lib/migrations/027-plan-at-creation-column.js +33 -0
  127. package/lib/migrations/028-ready-for-review-column.js +27 -0
  128. package/lib/migrations/029-remove-autoincrement.js +307 -0
  129. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  130. package/lib/migrations/index.js +47 -4
  131. package/lib/schema.js +13 -6
  132. package/lib/seed-onboarding.js +101 -69
  133. package/lib/update-command/index.js +9 -175
  134. package/lib/work-commands/index.js +129 -16
  135. package/lib/work-tracking/index.js +86 -46
  136. package/lib/worktree-diagnostics.js +16 -16
  137. package/lib/worktree-facade.js +1 -1
  138. package/lib/worktree-manager.js +8 -8
  139. package/lib/worktree-reconciler.js +5 -5
  140. package/package.json +9 -2
  141. package/scripts/ndjson-to-cucumber-json.js +152 -0
  142. package/scripts/postinstall.js +25 -0
  143. package/skills-templates/bug-mode/SKILL.md +39 -28
  144. package/skills-templates/bug-planning/SKILL.md +25 -29
  145. package/skills-templates/chore-mode/SKILL.md +131 -68
  146. package/skills-templates/chore-mode/verification.js +51 -10
  147. package/skills-templates/chore-planning/SKILL.md +47 -18
  148. package/skills-templates/epic-planning/SKILL.md +68 -48
  149. package/skills-templates/external-transition/SKILL.md +47 -47
  150. package/skills-templates/feature-planning/SKILL.md +83 -73
  151. package/skills-templates/production-mode/SKILL.md +49 -49
  152. package/skills-templates/request-routing/SKILL.md +27 -14
  153. package/skills-templates/simple-improvement/SKILL.md +68 -44
  154. package/skills-templates/speed-mode/SKILL.md +209 -128
  155. package/skills-templates/stable-mode/SKILL.md +105 -94
  156. package/templates/bdd-guidance.md +139 -0
  157. package/templates/bdd-scaffolding/wait.js +18 -0
  158. package/templates/bdd-scaffolding/world.js +19 -0
  159. package/.jettypod-backup/work.db +0 -0
  160. package/apps/dashboard/app/access-code/page.tsx +0 -110
  161. package/lib/discovery-checkpoint.js +0 -123
  162. 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
- // Access code gating
92
- access: {
93
- validate: (code) => ipcRenderer.invoke('access:validate', code),
94
- getStatus: () => ipcRenderer.invoke('access:getStatus'),
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 - publish to GitHub Releases
317
+ // Auto-update configuration - generic provider pointing to update server
318
318
  publish: {
319
- provider: 'github',
320
- owner: 'spangbaryn',
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 (30 minutes, only applies to unpinned sessions)
31
- const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
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
- const lines = data.toString().split('\n').filter(line => line.trim());
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(line);
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: line });
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: message,
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 5 minutes)
526
+ // Start periodic cleanup (every 15 minutes)
488
527
  setInterval(() => {
489
528
  cleanupIdleProcesses();
490
- }, 5 * 60 * 1000);
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
+