jettypod 4.4.118 → 4.4.121

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 (240) hide show
  1. package/.env +4 -3
  2. package/Cargo.lock +6450 -0
  3. package/Cargo.toml +35 -0
  4. package/README.md +5 -1
  5. package/TAURI-MIGRATION-PLAN.md +840 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +5 -6
  7. package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
  8. package/apps/dashboard/app/demo/gates/page.tsx +43 -45
  9. package/apps/dashboard/app/design-system/page.tsx +868 -0
  10. package/apps/dashboard/app/globals.css +80 -4
  11. package/apps/dashboard/app/install-claude/page.tsx +4 -6
  12. package/apps/dashboard/app/login/page.tsx +72 -54
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +61 -13
  15. package/apps/dashboard/app/signup/page.tsx +242 -0
  16. package/apps/dashboard/app/subscribe/page.tsx +0 -2
  17. package/apps/dashboard/app/tests/page.tsx +37 -4
  18. package/apps/dashboard/app/welcome/page.tsx +13 -16
  19. package/apps/dashboard/app/work/[id]/page.tsx +117 -118
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +92 -85
  22. package/apps/dashboard/components/CardMenu.tsx +45 -12
  23. package/apps/dashboard/components/ClaudePanel.tsx +771 -850
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
  26. package/apps/dashboard/components/CopyableId.tsx +3 -4
  27. package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
  28. package/apps/dashboard/components/DragContext.tsx +134 -63
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +6 -7
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +26 -7
  34. package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
  35. package/apps/dashboard/components/EpicGroup.tsx +359 -0
  36. package/apps/dashboard/components/GateCard.tsx +79 -17
  37. package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
  39. package/apps/dashboard/components/JettyLoader.tsx +37 -0
  40. package/apps/dashboard/components/KanbanBoard.tsx +368 -958
  41. package/apps/dashboard/components/KanbanCard.tsx +740 -0
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
  44. package/apps/dashboard/components/MainNav.tsx +38 -73
  45. package/apps/dashboard/components/MessageBlock.tsx +468 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -16
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
  53. package/apps/dashboard/components/ReviewFooter.tsx +139 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -19
  55. package/apps/dashboard/components/SubscribeContent.tsx +91 -47
  56. package/apps/dashboard/components/TestTree.tsx +16 -16
  57. package/apps/dashboard/components/TipCard.tsx +16 -17
  58. package/apps/dashboard/components/Toast.tsx +5 -6
  59. package/apps/dashboard/components/TypeIcon.tsx +55 -0
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
  62. package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
  64. package/apps/dashboard/components/WorkItemTree.tsx +11 -32
  65. package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
  66. package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
  67. package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
  68. package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
  72. package/apps/dashboard/components/ui/Button.tsx +104 -0
  73. package/apps/dashboard/components/ui/Input.tsx +78 -0
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
  77. package/apps/dashboard/contexts/UsageContext.tsx +87 -32
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  83. package/apps/dashboard/index.html +73 -0
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/data-bridge.ts +722 -0
  86. package/apps/dashboard/lib/db.ts +69 -1265
  87. package/apps/dashboard/lib/environment-config.ts +173 -0
  88. package/apps/dashboard/lib/environment-verification.ts +119 -0
  89. package/apps/dashboard/lib/kanban-utils.ts +270 -0
  90. package/apps/dashboard/lib/proof-run.ts +495 -0
  91. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  92. package/apps/dashboard/lib/run-migrations.js +27 -2
  93. package/apps/dashboard/lib/service-recovery.ts +326 -0
  94. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  95. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  96. package/apps/dashboard/lib/session-stream-manager.ts +308 -134
  97. package/apps/dashboard/lib/shadows.ts +7 -0
  98. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  99. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  100. package/apps/dashboard/lib/tauri.ts +106 -0
  101. package/apps/dashboard/lib/utils.ts +6 -0
  102. package/apps/dashboard/next-env.d.ts +1 -1
  103. package/apps/dashboard/package.json +21 -32
  104. package/apps/dashboard/public/bug-icon.png +0 -0
  105. package/apps/dashboard/public/buoy-icon.png +0 -0
  106. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  107. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  108. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  109. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  110. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  111. package/apps/dashboard/public/jettypod_logo.png +0 -0
  112. package/apps/dashboard/public/pier-icon.png +0 -0
  113. package/apps/dashboard/public/star-icon.png +0 -0
  114. package/apps/dashboard/public/wrench-icon.png +0 -0
  115. package/apps/dashboard/scripts/tauri-build.js +228 -0
  116. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  117. package/apps/dashboard/scripts/ws-server.js +191 -0
  118. package/apps/dashboard/src/main.tsx +12 -0
  119. package/apps/dashboard/src/router.tsx +107 -0
  120. package/apps/dashboard/src/vite-env.d.ts +1 -0
  121. package/apps/dashboard/tsconfig.json +7 -12
  122. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  123. package/apps/dashboard/vite.config.ts +33 -0
  124. package/apps/update-server/src/index.ts +228 -80
  125. package/claude-hooks/global-guardrails.js +14 -13
  126. package/crates/jettypod-cli/Cargo.toml +19 -0
  127. package/crates/jettypod-cli/src/commands.rs +1249 -0
  128. package/crates/jettypod-cli/src/main.rs +595 -0
  129. package/crates/jettypod-core/Cargo.toml +26 -0
  130. package/crates/jettypod-core/build.rs +98 -0
  131. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  132. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  133. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  134. package/crates/jettypod-core/src/auth.rs +294 -0
  135. package/crates/jettypod-core/src/config.rs +397 -0
  136. package/crates/jettypod-core/src/db/mod.rs +507 -0
  137. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  138. package/crates/jettypod-core/src/db/startup.rs +101 -0
  139. package/crates/jettypod-core/src/db/validate.rs +149 -0
  140. package/crates/jettypod-core/src/error.rs +76 -0
  141. package/crates/jettypod-core/src/git.rs +458 -0
  142. package/crates/jettypod-core/src/lib.rs +20 -0
  143. package/crates/jettypod-core/src/sessions.rs +625 -0
  144. package/crates/jettypod-core/src/skills.rs +556 -0
  145. package/crates/jettypod-core/src/work.rs +1086 -0
  146. package/crates/jettypod-core/src/worktree.rs +628 -0
  147. package/crates/jettypod-core/src/ws.rs +767 -0
  148. package/cucumber-test.cjs +6 -0
  149. package/cucumber.js +9 -3
  150. package/docs/COMMAND_REFERENCE.md +34 -0
  151. package/hooks/post-checkout +32 -75
  152. package/hooks/post-merge +111 -10
  153. package/jest.setup.js +1 -0
  154. package/jettypod.js +145 -116
  155. package/lib/bdd-preflight.js +96 -0
  156. package/lib/chore-taxonomy.js +33 -10
  157. package/lib/database.js +36 -16
  158. package/lib/db-watcher.js +1 -1
  159. package/lib/git-hooks/pre-commit +1 -1
  160. package/lib/jettypod-backup.js +27 -4
  161. package/lib/merge-lock.js +111 -253
  162. package/lib/migrations/027-plan-at-creation-column.js +3 -1
  163. package/lib/migrations/029-remove-autoincrement.js +307 -0
  164. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  165. package/lib/migrations/030-rejection-round-columns.js +54 -0
  166. package/lib/migrations/031-session-isolation-index.js +17 -0
  167. package/lib/migrations/index.js +47 -4
  168. package/lib/schema.js +10 -5
  169. package/lib/seed-onboarding.js +1 -1
  170. package/lib/update-command/index.js +9 -175
  171. package/lib/work-commands/index.js +144 -19
  172. package/lib/work-tracking/index.js +148 -27
  173. package/lib/worktree-diagnostics.js +16 -16
  174. package/lib/worktree-facade.js +1 -1
  175. package/lib/worktree-manager.js +8 -8
  176. package/lib/worktree-reconciler.js +5 -5
  177. package/package.json +9 -2
  178. package/scripts/ndjson-to-cucumber-json.js +152 -0
  179. package/scripts/postinstall.js +25 -0
  180. package/skills-templates/bug-mode/SKILL.md +79 -20
  181. package/skills-templates/bug-planning/SKILL.md +25 -29
  182. package/skills-templates/chore-mode/SKILL.md +171 -69
  183. package/skills-templates/chore-mode/verification.js +51 -10
  184. package/skills-templates/chore-planning/SKILL.md +47 -18
  185. package/skills-templates/design-system-selection/SKILL.md +273 -0
  186. package/skills-templates/epic-planning/SKILL.md +82 -48
  187. package/skills-templates/external-transition/SKILL.md +47 -47
  188. package/skills-templates/feature-planning/SKILL.md +173 -74
  189. package/skills-templates/production-mode/SKILL.md +69 -49
  190. package/skills-templates/request-routing/SKILL.md +4 -4
  191. package/skills-templates/simple-improvement/SKILL.md +74 -29
  192. package/skills-templates/speed-mode/SKILL.md +217 -141
  193. package/skills-templates/stable-mode/SKILL.md +148 -89
  194. package/apps/dashboard/README.md +0 -36
  195. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
  196. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  197. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
  198. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  199. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
  200. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  201. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  202. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  203. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  204. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  205. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  206. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  207. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  208. package/apps/dashboard/app/api/tests/route.ts +0 -9
  209. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  210. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  211. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  212. package/apps/dashboard/app/api/usage/route.ts +0 -17
  213. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  214. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  215. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  216. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
  217. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  218. package/apps/dashboard/app/layout.tsx +0 -43
  219. package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
  220. package/apps/dashboard/electron/ipc-handlers.js +0 -1028
  221. package/apps/dashboard/electron/main.js +0 -2124
  222. package/apps/dashboard/electron/preload.js +0 -123
  223. package/apps/dashboard/electron/session-manager.js +0 -141
  224. package/apps/dashboard/electron-builder.config.js +0 -357
  225. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  226. package/apps/dashboard/lib/claude-process-manager.ts +0 -492
  227. package/apps/dashboard/lib/db-bridge.ts +0 -282
  228. package/apps/dashboard/lib/prototypes.ts +0 -202
  229. package/apps/dashboard/lib/test-results-db.ts +0 -307
  230. package/apps/dashboard/lib/tests.ts +0 -282
  231. package/apps/dashboard/next.config.js +0 -50
  232. package/apps/dashboard/postcss.config.mjs +0 -7
  233. package/apps/dashboard/public/file.svg +0 -1
  234. package/apps/dashboard/public/globe.svg +0 -1
  235. package/apps/dashboard/public/next.svg +0 -1
  236. package/apps/dashboard/public/vercel.svg +0 -1
  237. package/apps/dashboard/public/window.svg +0 -1
  238. package/apps/dashboard/scripts/download-node.js +0 -104
  239. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
  240. package/docs/bdd-guidance.md +0 -390
@@ -1,21 +1,42 @@
1
- 'use client';
2
1
 
3
- import { createContext, useContext, useState, type ReactNode } from 'react';
2
+ import { createContext, useContext, useState, useEffect, useRef, useMemo, type ReactNode } from 'react';
4
3
 
5
4
  export type ConnectionStatus = 'connected' | 'reconnecting' | 'disconnected';
6
5
 
6
+ const DISCONNECT_DELAY_MS = 5000;
7
+
7
8
  interface ConnectionStatusContextValue {
8
9
  status: ConnectionStatus;
9
10
  setStatus: (status: ConnectionStatus) => void;
11
+ showDisconnected: boolean;
10
12
  }
11
13
 
12
14
  const ConnectionStatusContext = createContext<ConnectionStatusContextValue | null>(null);
13
15
 
14
16
  export function ConnectionStatusProvider({ children }: { children: ReactNode }) {
15
17
  const [status, setStatus] = useState<ConnectionStatus>('disconnected');
18
+ const [showDisconnected, setShowDisconnected] = useState(false);
19
+ const timerRef = useRef<NodeJS.Timeout | null>(null);
20
+
21
+ useEffect(() => {
22
+ if (status === 'connected') {
23
+ if (timerRef.current) clearTimeout(timerRef.current);
24
+ setShowDisconnected(false);
25
+ } else {
26
+ timerRef.current = setTimeout(() => {
27
+ setShowDisconnected(true);
28
+ }, DISCONNECT_DELAY_MS);
29
+ }
30
+
31
+ return () => {
32
+ if (timerRef.current) clearTimeout(timerRef.current);
33
+ };
34
+ }, [status]);
35
+
36
+ const value = useMemo(() => ({ status, setStatus, showDisconnected }), [status, showDisconnected]);
16
37
 
17
38
  return (
18
- <ConnectionStatusContext.Provider value={{ status, setStatus }}>
39
+ <ConnectionStatusContext.Provider value={value}>
19
40
  {children}
20
41
  </ConnectionStatusContext.Provider>
21
42
  );
@@ -24,8 +45,7 @@ export function ConnectionStatusProvider({ children }: { children: ReactNode })
24
45
  export function useConnectionStatus() {
25
46
  const context = useContext(ConnectionStatusContext);
26
47
  if (!context) {
27
- // Return a safe default when used outside the provider
28
- return { status: 'disconnected' as ConnectionStatus, setStatus: () => {} };
48
+ return { status: 'disconnected' as ConnectionStatus, setStatus: () => {}, showDisconnected: false };
29
49
  }
30
50
  return context;
31
51
  }
@@ -1,9 +1,13 @@
1
- 'use client';
2
1
 
3
- import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
2
+ import { createContext, useContext, useState, useEffect, useCallback, useMemo, useRef, type ReactNode } from 'react';
4
3
  import { useWebSocket, type WebSocketMessage } from '../hooks/useWebSocket';
4
+ import { getWebSocketUrl } from '../lib/utils';
5
+ import { isTauri, auth } from '@/lib/tauri-bridge';
6
+ import { invoke } from '@/lib/tauri';
7
+ import { dataBridge } from '@/lib/data-bridge';
5
8
 
6
9
  const FREE_WEEKLY_LIMIT = 20;
10
+ const API_BASE = 'https://jettypod-update-server.spangbaryn2.workers.dev';
7
11
 
8
12
  interface UsageState {
9
13
  used: number;
@@ -30,15 +34,51 @@ export function UsageProvider({ children }: { children: ReactNode }) {
30
34
  loading: true,
31
35
  });
32
36
 
37
+ // Don't hit DB or WS until a project is actually loaded
38
+ const [projectLoaded, setProjectLoaded] = useState(false);
39
+ useEffect(() => {
40
+ dataBridge.getProjectRoot().then((root) => {
41
+ if (root) setProjectLoaded(true);
42
+ });
43
+ }, []);
44
+
45
+ const lastServerCheckRef = useRef<number>(0);
46
+
33
47
  const fetchUsage = useCallback(async () => {
34
- // Get plan from auth (if Electron)
48
+ // Get plan from auth (if Tauri)
35
49
  let plan = 'free';
36
50
  try {
37
- if (window.electronAPI?.isElectron) {
38
- const status = await window.electronAPI.auth.getStatus();
51
+ if (isTauri()) {
52
+ const status = await auth.getStatus();
39
53
  if (status.authenticated && status.user) {
40
54
  plan = status.user.plan || 'free';
41
55
  }
56
+
57
+ // Check server-side plan — the local JWT may be stale
58
+ // Only check every 10 minutes to avoid unnecessary API calls
59
+ if (plan === 'free' && (!lastServerCheckRef.current || Date.now() - lastServerCheckRef.current > 600_000)) {
60
+ const token = await auth.getToken();
61
+ if (token) {
62
+ try {
63
+ const res = await fetch(`${API_BASE}/auth/me`, {
64
+ headers: { 'Authorization': `Bearer ${token}` },
65
+ });
66
+ lastServerCheckRef.current = Date.now();
67
+ if (res.ok) {
68
+ const data = await res.json() as { user: { plan: string; email: string }; token?: string };
69
+ if (data.user.plan !== 'free') {
70
+ plan = data.user.plan;
71
+ // Server issued a fresh JWT with updated plan — save it locally
72
+ if (data.token) {
73
+ await auth.saveToken(data.token, data.user);
74
+ }
75
+ }
76
+ }
77
+ } catch {
78
+ // Network error — fall through with local plan
79
+ }
80
+ }
81
+ }
42
82
  }
43
83
  } catch {
44
84
  // Auth unavailable — default to free
@@ -57,58 +97,73 @@ export function UsageProvider({ children }: { children: ReactNode }) {
57
97
  return;
58
98
  }
59
99
 
60
- // Free plan — count from local DB via API route
100
+ // Free plan — count active sessions via Tauri IPC
61
101
  try {
62
- const res = await fetch('/api/usage');
63
- console.log('[usage] UsageContext fetch status:', res.status);
64
- if (!res.ok) {
65
- console.error('[usage] UsageContext fetch not ok:', res.status, res.statusText);
66
- setState(prev => ({ ...prev, allowed: true, loading: false }));
67
- return;
68
- }
69
- const usage = await res.json();
70
- console.log('[usage] UsageContext received:', usage);
102
+ const count = await invoke<number>('db_get_active_session_count');
103
+ const used = typeof count === 'number' ? count : 0;
104
+ const remaining = Math.max(0, FREE_WEEKLY_LIMIT - used);
71
105
  setState({
72
- used: typeof usage.used === 'number' ? usage.used : 0,
73
- limit: typeof usage.limit === 'number' ? usage.limit : FREE_WEEKLY_LIMIT,
74
- remaining: typeof usage.remaining === 'number' ? usage.remaining : FREE_WEEKLY_LIMIT,
75
- allowed: typeof usage.allowed === 'boolean' ? usage.allowed : true,
106
+ used,
107
+ limit: FREE_WEEKLY_LIMIT,
108
+ remaining,
109
+ allowed: remaining > 0,
76
110
  plan,
77
111
  loading: false,
78
112
  });
79
113
  } catch (err) {
80
- console.error('[usage] UsageContext fetch error:', err);
114
+ console.error('[usage] UsageContext IPC error:', err);
81
115
  setState(prev => ({ ...prev, allowed: true, loading: false }));
82
116
  }
83
117
  }, []);
84
118
 
85
- // Fetch on mount
119
+ // Fetch on mount (only after project is loaded)
86
120
  useEffect(() => {
87
- fetchUsage();
88
- }, [fetchUsage]);
121
+ if (projectLoaded) fetchUsage();
122
+ }, [projectLoaded, fetchUsage]);
89
123
 
90
124
  // Refresh on window focus
91
125
  useEffect(() => {
126
+ if (!projectLoaded) return;
92
127
  const handleFocus = () => fetchUsage();
93
128
  window.addEventListener('focus', handleFocus);
94
129
  return () => window.removeEventListener('focus', handleFocus);
95
- }, [fetchUsage]);
130
+ }, [projectLoaded, fetchUsage]);
96
131
 
97
- // Refresh on WebSocket db_change events (e.g., CLI creates a work item)
132
+ // Refresh on WebSocket db_change events debounced and throttled.
133
+ // Skip entirely for paid plans (usage is unlimited).
134
+ // Minimum 30s between db_change-triggered fetches to avoid IPC storms.
135
+ const usageDebounceRef = useRef<NodeJS.Timeout | null>(null);
136
+ const lastDbChangeFetchRef = useRef<number>(0);
98
137
  const handleWsMessage = useCallback((message: WebSocketMessage) => {
99
138
  if (message.type === 'db_change') {
100
- fetchUsage();
139
+ // Paid plans have unlimited usage — no need to refresh
140
+ if (state.plan !== 'free') return;
141
+
142
+ if (usageDebounceRef.current) clearTimeout(usageDebounceRef.current);
143
+ usageDebounceRef.current = setTimeout(() => {
144
+ usageDebounceRef.current = null;
145
+ // Throttle: skip if we fetched recently
146
+ const now = Date.now();
147
+ if (now - lastDbChangeFetchRef.current < 30_000) return;
148
+ lastDbChangeFetchRef.current = now;
149
+ fetchUsage();
150
+ }, 5000);
101
151
  }
102
- }, [fetchUsage]);
152
+ }, [fetchUsage, state.plan]);
153
+
154
+ useWebSocket({ url: projectLoaded ? getWebSocketUrl() : '', onMessage: handleWsMessage });
103
155
 
104
- const wsUrl = typeof window !== 'undefined'
105
- ? `ws://${window.location.hostname}:47808`
106
- : 'ws://localhost:47808';
156
+ // Cleanup debounce timer
157
+ useEffect(() => {
158
+ return () => {
159
+ if (usageDebounceRef.current) clearTimeout(usageDebounceRef.current);
160
+ };
161
+ }, []);
107
162
 
108
- useWebSocket({ url: wsUrl, onMessage: handleWsMessage });
163
+ const value = useMemo(() => ({ ...state, refresh: fetchUsage }), [state, fetchUsage]);
109
164
 
110
165
  return (
111
- <UsageContext.Provider value={{ ...state, refresh: fetchUsage }}>
166
+ <UsageContext.Provider value={value}>
112
167
  {children}
113
168
  </UsageContext.Provider>
114
169
  );
@@ -0,0 +1,35 @@
1
+ #!/bin/bash
2
+ # Start Vite + Tauri for local development
3
+ # All child processes are killed when this script exits (Ctrl-C, close terminal, etc.)
4
+ set -e
5
+
6
+ export PATH="$HOME/.cargo/bin:$PATH"
7
+ cd "$(dirname "$0")"
8
+
9
+ # Kill any leftover processes on port 1420
10
+ lsof -ti:1420 | xargs kill 2>/dev/null || true
11
+
12
+ # Swap to dev icon and dev identifier for local builds
13
+ # Dev identifier prevents single-instance plugin from conflicting with installed app
14
+ cp src-tauri/icons/icon-dev.png src-tauri/icons/icon.png
15
+ sed -i '' 's/"identifier": "com.jettypod.app"/"identifier": "com.jettypod.app.dev"/' src-tauri/tauri.conf.json
16
+ sed -i '' 's/"productName": "JettyPod"/"productName": "JettyPod Dev"/' src-tauri/tauri.conf.json
17
+
18
+ # Kill the entire process group on exit — catches Vite, cargo, tauri, rustc, etc.
19
+ # Also restore the production icon and identifier so git stays clean.
20
+ cleanup() {
21
+ trap - INT TERM EXIT
22
+ git checkout src-tauri/icons/icon.png src-tauri/tauri.conf.json 2>/dev/null || true
23
+ kill -- -$$ 2>/dev/null
24
+ }
25
+ trap cleanup INT TERM EXIT
26
+
27
+ npx vite --port 1420 &
28
+
29
+ echo "Waiting for Vite on :1420..."
30
+ while ! curl -s http://localhost:1420 > /dev/null 2>&1; do
31
+ sleep 0.5
32
+ done
33
+
34
+ echo "Vite ready. Starting Tauri..."
35
+ cargo tauri dev
@@ -1,18 +1,18 @@
1
1
  import { defineConfig, globalIgnores } from "eslint/config";
2
- import nextVitals from "eslint-config-next/core-web-vitals";
3
- import nextTs from "eslint-config-next/typescript";
4
2
 
5
3
  const eslintConfig = defineConfig([
6
- ...nextVitals,
7
- ...nextTs,
8
- // Override default ignores of eslint-config-next.
9
4
  globalIgnores([
10
- // Default ignores of eslint-config-next:
11
- ".next/**",
12
- "out/**",
5
+ "dist/**",
13
6
  "build/**",
14
- "next-env.d.ts",
7
+ "src-tauri/**",
15
8
  ]),
9
+ {
10
+ files: ["**/*.{ts,tsx,js,jsx,mjs}"],
11
+ rules: {
12
+ "no-unused-vars": "off",
13
+ "no-undef": "off",
14
+ },
15
+ },
16
16
  ]);
17
17
 
18
18
  export default eslintConfig;
@@ -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
+ }
@@ -1,11 +1,14 @@
1
- 'use client';
2
1
 
3
- import { useEffect, useRef, useCallback, useState } from 'react';
2
+ import { useEffect, useRef, useState, useCallback } from 'react';
4
3
 
5
4
  export interface WebSocketMessage {
6
5
  type: string;
7
6
  timestamp: number;
8
7
  event?: string;
8
+ /** Delta fields — present when type === 'db_delta' */
9
+ table?: string;
10
+ rowid?: number;
11
+ action?: 'insert' | 'update' | 'delete';
9
12
  }
10
13
 
11
14
  interface UseWebSocketOptions {
@@ -22,103 +25,155 @@ interface UseWebSocketReturn {
22
25
  manualReconnect: () => void;
23
26
  }
24
27
 
25
- export function useWebSocket({
26
- url,
27
- onMessage,
28
- reconnectInterval = 3000,
29
- maxReconnectAttempts = 5,
30
- }: UseWebSocketOptions): UseWebSocketReturn {
31
- const [isConnected, setIsConnected] = useState(false);
32
- const [isReconnecting, setIsReconnecting] = useState(false);
33
- const [reconnectionFailed, setReconnectionFailed] = useState(false);
34
- const wsRef = useRef<WebSocket | null>(null);
35
- const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
36
- const reconnectAttemptsRef = useRef(0);
37
- const onMessageRef = useRef(onMessage);
28
+ // ---- Shared singleton WebSocket connection ----
38
29
 
39
- // Keep the ref current without triggering reconnects
40
- useEffect(() => {
41
- onMessageRef.current = onMessage;
42
- }, [onMessage]);
30
+ type Subscriber = (message: WebSocketMessage) => void;
31
+
32
+ let sharedWs: WebSocket | null = null;
33
+ let sharedUrl: string | null = null;
34
+ let subscribers = new Set<Subscriber>();
35
+ let connectionState: 'disconnected' | 'connected' | 'reconnecting' | 'failed' = 'disconnected';
36
+ let reconnectAttempts = 0;
37
+ let reconnectTimeout: NodeJS.Timeout | null = null;
38
+ let stateListeners = new Set<() => void>();
43
39
 
44
- const connect = useCallback(() => {
45
- if (wsRef.current?.readyState === WebSocket.OPEN) {
40
+ const RECONNECT_INTERVAL = 3000;
41
+ const MAX_RECONNECT_ATTEMPTS = 5;
42
+
43
+ function notifyStateChange() {
44
+ for (const listener of stateListeners) listener();
45
+ }
46
+
47
+ function connectShared(url: string) {
48
+ if (sharedWs?.readyState === WebSocket.OPEN || sharedWs?.readyState === WebSocket.CONNECTING) return;
49
+
50
+ sharedUrl = url;
51
+ const ws = new WebSocket(url);
52
+
53
+ ws.onopen = () => {
54
+ connectionState = 'connected';
55
+ reconnectAttempts = 0;
56
+ // Expose to window for test assertions
57
+ (window as unknown as { __wsConnection: WebSocket }).__wsConnection = ws;
58
+ (window as unknown as { __wsReconnecting: boolean }).__wsReconnecting = false;
59
+ notifyStateChange();
60
+ };
61
+
62
+ ws.onmessage = (event) => {
63
+ try {
64
+ const message = JSON.parse(event.data) as WebSocketMessage;
65
+ for (const sub of subscribers) sub(message);
66
+ } catch (e) {
67
+ console.warn('Received malformed WebSocket message:', e);
68
+ }
69
+ };
70
+
71
+ ws.onclose = () => {
72
+ // Only clear if this is still the active connection — prevents a stale
73
+ // WS (closed during CONNECTING by React Strict Mode) from nulling out
74
+ // the newer replacement connection.
75
+ if (sharedWs !== ws) return;
76
+ sharedWs = null;
77
+
78
+ if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
79
+ connectionState = 'failed';
80
+ notifyStateChange();
46
81
  return;
47
82
  }
48
83
 
49
- const ws = new WebSocket(url);
50
-
51
- ws.onopen = () => {
52
- setIsConnected(true);
53
- setIsReconnecting(false);
54
- setReconnectionFailed(false);
55
- reconnectAttemptsRef.current = 0;
56
- // Expose to window for test assertions
57
- if (typeof window !== 'undefined') {
58
- (window as unknown as { __wsConnection: WebSocket }).__wsConnection = ws;
59
- (window as unknown as { __wsReconnecting: boolean }).__wsReconnecting = false;
60
- }
61
- };
84
+ reconnectAttempts += 1;
85
+ connectionState = 'reconnecting';
86
+ (window as unknown as { __wsReconnecting: boolean }).__wsReconnecting = true;
87
+ notifyStateChange();
62
88
 
63
- ws.onmessage = (event) => {
64
- try {
65
- const message = JSON.parse(event.data) as WebSocketMessage;
66
- onMessageRef.current?.(message);
67
- } catch (e) {
68
- // Gracefully handle malformed messages - log but don't crash
69
- console.warn('Received malformed WebSocket message:', e);
70
- }
71
- };
89
+ reconnectTimeout = setTimeout(() => {
90
+ if (subscribers.size > 0 && sharedUrl) connectShared(sharedUrl);
91
+ }, RECONNECT_INTERVAL);
92
+ };
72
93
 
73
- ws.onclose = () => {
74
- setIsConnected(false);
75
- wsRef.current = null;
94
+ ws.onerror = () => {
95
+ ws.close();
96
+ };
76
97
 
77
- // Check if max reconnection attempts reached
78
- if (reconnectAttemptsRef.current >= maxReconnectAttempts) {
79
- setIsReconnecting(false);
80
- setReconnectionFailed(true);
81
- return;
82
- }
98
+ sharedWs = ws;
99
+ }
83
100
 
84
- // Initiate reconnection
85
- reconnectAttemptsRef.current += 1;
86
- setIsReconnecting(true);
87
- if (typeof window !== 'undefined') {
88
- (window as unknown as { __wsReconnecting: boolean }).__wsReconnecting = true;
89
- }
101
+ function subscribe(url: string, callback: Subscriber): () => void {
102
+ subscribers.add(callback);
90
103
 
91
- reconnectTimeoutRef.current = setTimeout(() => {
92
- connect();
93
- }, reconnectInterval);
94
- };
104
+ // Start connection if this is the first subscriber
105
+ if (subscribers.size === 1 || (!sharedWs && connectionState !== 'failed')) {
106
+ connectShared(url);
107
+ }
95
108
 
96
- ws.onerror = () => {
97
- ws.close();
98
- };
109
+ return () => {
110
+ subscribers.delete(callback);
99
111
 
100
- wsRef.current = ws;
101
- }, [url, reconnectInterval, maxReconnectAttempts]);
112
+ // Close connection when last subscriber leaves
113
+ if (subscribers.size === 0) {
114
+ if (reconnectTimeout) clearTimeout(reconnectTimeout);
115
+ if (sharedWs) {
116
+ sharedWs.close();
117
+ sharedWs = null;
118
+ }
119
+ connectionState = 'disconnected';
120
+ reconnectAttempts = 0;
121
+ sharedUrl = null;
122
+ }
123
+ };
124
+ }
102
125
 
103
- const manualReconnect = useCallback(() => {
104
- // Reset reconnection state and attempt to connect
105
- reconnectAttemptsRef.current = 0;
106
- setReconnectionFailed(false);
107
- connect();
108
- }, [connect]);
126
+ // ---- React hook (thin wrapper over shared connection) ----
127
+
128
+ // Derive a simple state key from the connection state so we only re-render
129
+ // when the user-visible status actually changes — not on every notifyStateChange
130
+ // (which fires on each reconnect attempt, causing cascade re-renders in all
131
+ // components that use this hook).
132
+ function getStateKey(): string {
133
+ return connectionState;
134
+ }
135
+
136
+ export function useWebSocket({
137
+ url,
138
+ onMessage,
139
+ }: UseWebSocketOptions): UseWebSocketReturn {
140
+ const [stateKey, setStateKey] = useState(getStateKey);
141
+ const onMessageRef = useRef(onMessage);
109
142
 
110
143
  useEffect(() => {
111
- connect();
144
+ onMessageRef.current = onMessage;
145
+ }, [onMessage]);
112
146
 
113
- return () => {
114
- if (reconnectTimeoutRef.current) {
115
- clearTimeout(reconnectTimeoutRef.current);
116
- }
117
- if (wsRef.current) {
118
- wsRef.current.close();
119
- }
147
+ // Subscribe to state changes — only re-render when the derived state key
148
+ // actually changes. Previous implementation used forceRender(n => n+1) which
149
+ // re-rendered on EVERY notifyStateChange regardless of whether the displayed
150
+ // status changed. With 3+ consumers this caused cascade re-renders.
151
+ useEffect(() => {
152
+ const listener = () => {
153
+ const newKey = getStateKey();
154
+ setStateKey(prev => prev === newKey ? prev : newKey);
120
155
  };
121
- }, [connect]);
156
+ stateListeners.add(listener);
157
+ return () => { stateListeners.delete(listener); };
158
+ }, []);
122
159
 
123
- return { isConnected, isReconnecting, reconnectionFailed, manualReconnect };
160
+ // Subscribe to messages (skip when URL is empty — no project loaded)
161
+ useEffect(() => {
162
+ if (!url) return;
163
+ const callback: Subscriber = (msg) => onMessageRef.current?.(msg);
164
+ return subscribe(url, callback);
165
+ }, [url]);
166
+
167
+ const manualReconnect = useCallback(() => {
168
+ reconnectAttempts = 0;
169
+ connectionState = 'disconnected';
170
+ if (sharedUrl) connectShared(sharedUrl);
171
+ }, []);
172
+
173
+ return {
174
+ isConnected: stateKey === 'connected',
175
+ isReconnecting: stateKey === 'reconnecting',
176
+ reconnectionFailed: stateKey === 'failed',
177
+ manualReconnect,
178
+ };
124
179
  }