jettypod 4.4.120 → 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 (208) hide show
  1. package/.env +2 -1
  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 +54 -49
  8. package/apps/dashboard/app/demo/gates/page.tsx +3 -5
  9. package/apps/dashboard/app/design-system/page.tsx +1 -1
  10. package/apps/dashboard/app/globals.css +74 -2
  11. package/apps/dashboard/app/install-claude/page.tsx +3 -5
  12. package/apps/dashboard/app/login/page.tsx +17 -20
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +60 -12
  15. package/apps/dashboard/app/signup/page.tsx +14 -17
  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 +12 -15
  19. package/apps/dashboard/app/work/[id]/page.tsx +90 -75
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +70 -61
  22. package/apps/dashboard/components/CardMenu.tsx +0 -1
  23. package/apps/dashboard/components/ClaudePanel.tsx +541 -283
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +23 -4
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +1 -5
  26. package/apps/dashboard/components/CopyableId.tsx +1 -2
  27. package/apps/dashboard/components/DetailReviewActions.tsx +11 -20
  28. package/apps/dashboard/components/DragContext.tsx +132 -62
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +5 -6
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +6 -12
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +0 -1
  34. package/apps/dashboard/components/ElapsedTimer.tsx +15 -3
  35. package/apps/dashboard/components/EpicGroup.tsx +100 -70
  36. package/apps/dashboard/components/GateCard.tsx +0 -1
  37. package/apps/dashboard/components/GateChoiceCard.tsx +1 -2
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +1 -5
  39. package/apps/dashboard/components/JettyLoader.tsx +0 -1
  40. package/apps/dashboard/components/KanbanBoard.tsx +319 -173
  41. package/apps/dashboard/components/KanbanCard.tsx +341 -107
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +0 -1
  44. package/apps/dashboard/components/MainNav.tsx +24 -25
  45. package/apps/dashboard/components/MessageBlock.tsx +93 -16
  46. package/apps/dashboard/components/ModeStartCard.tsx +0 -1
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +0 -1
  48. package/apps/dashboard/components/PlaceholderCard.tsx +0 -1
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +20 -20
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +47 -26
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +308 -223
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +303 -160
  53. package/apps/dashboard/components/ReviewFooter.tsx +12 -14
  54. package/apps/dashboard/components/SessionList.tsx +0 -1
  55. package/apps/dashboard/components/SubscribeContent.tsx +40 -11
  56. package/apps/dashboard/components/TestTree.tsx +1 -2
  57. package/apps/dashboard/components/TipCard.tsx +2 -4
  58. package/apps/dashboard/components/Toast.tsx +0 -1
  59. package/apps/dashboard/components/TypeIcon.tsx +7 -8
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +5 -17
  62. package/apps/dashboard/components/WelcomeScreen.tsx +2 -6
  63. package/apps/dashboard/components/WorkItemHeader.tsx +0 -1
  64. package/apps/dashboard/components/WorkItemTree.tsx +2 -4
  65. package/apps/dashboard/components/settings/AccountSection.tsx +27 -13
  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 +20 -73
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +137 -26
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +0 -1
  72. package/apps/dashboard/components/ui/Button.tsx +1 -1
  73. package/apps/dashboard/components/ui/Input.tsx +1 -1
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +611 -358
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +0 -1
  77. package/apps/dashboard/contexts/UsageContext.tsx +62 -31
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  81. package/apps/dashboard/index.html +73 -0
  82. package/apps/dashboard/lib/data-bridge.ts +722 -0
  83. package/apps/dashboard/lib/db.ts +69 -1302
  84. package/apps/dashboard/lib/environment-config.ts +173 -0
  85. package/apps/dashboard/lib/environment-verification.ts +119 -0
  86. package/apps/dashboard/lib/kanban-utils.ts +226 -26
  87. package/apps/dashboard/lib/proof-run.ts +495 -0
  88. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  89. package/apps/dashboard/lib/service-recovery.ts +326 -0
  90. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  91. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  92. package/apps/dashboard/lib/session-stream-manager.ts +253 -122
  93. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  94. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  95. package/apps/dashboard/lib/tauri.ts +106 -0
  96. package/apps/dashboard/lib/utils.ts +3 -3
  97. package/apps/dashboard/next-env.d.ts +1 -1
  98. package/apps/dashboard/package.json +21 -33
  99. package/apps/dashboard/public/bug-icon.png +0 -0
  100. package/apps/dashboard/public/buoy-icon.png +0 -0
  101. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  102. package/apps/dashboard/public/pier-icon.png +0 -0
  103. package/apps/dashboard/public/star-icon.png +0 -0
  104. package/apps/dashboard/public/wrench-icon.png +0 -0
  105. package/apps/dashboard/scripts/tauri-build.js +228 -0
  106. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  107. package/apps/dashboard/src/main.tsx +12 -0
  108. package/apps/dashboard/src/router.tsx +107 -0
  109. package/apps/dashboard/src/vite-env.d.ts +1 -0
  110. package/apps/dashboard/tsconfig.json +7 -12
  111. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  112. package/apps/dashboard/vite.config.ts +33 -0
  113. package/apps/update-server/src/index.ts +167 -30
  114. package/claude-hooks/global-guardrails.js +14 -13
  115. package/crates/jettypod-cli/Cargo.toml +19 -0
  116. package/crates/jettypod-cli/src/commands.rs +1249 -0
  117. package/crates/jettypod-cli/src/main.rs +595 -0
  118. package/crates/jettypod-core/Cargo.toml +26 -0
  119. package/crates/jettypod-core/build.rs +98 -0
  120. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  121. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  122. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  123. package/crates/jettypod-core/src/auth.rs +294 -0
  124. package/crates/jettypod-core/src/config.rs +397 -0
  125. package/crates/jettypod-core/src/db/mod.rs +507 -0
  126. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  127. package/crates/jettypod-core/src/db/startup.rs +101 -0
  128. package/crates/jettypod-core/src/db/validate.rs +149 -0
  129. package/crates/jettypod-core/src/error.rs +76 -0
  130. package/crates/jettypod-core/src/git.rs +458 -0
  131. package/crates/jettypod-core/src/lib.rs +20 -0
  132. package/crates/jettypod-core/src/sessions.rs +625 -0
  133. package/crates/jettypod-core/src/skills.rs +556 -0
  134. package/crates/jettypod-core/src/work.rs +1086 -0
  135. package/crates/jettypod-core/src/worktree.rs +628 -0
  136. package/crates/jettypod-core/src/ws.rs +767 -0
  137. package/cucumber-test.cjs +6 -0
  138. package/jettypod.js +96 -4
  139. package/lib/bdd-preflight.js +96 -0
  140. package/lib/merge-lock.js +111 -253
  141. package/lib/migrations/030-rejection-round-columns.js +54 -0
  142. package/lib/migrations/031-session-isolation-index.js +17 -0
  143. package/lib/work-commands/index.js +58 -16
  144. package/lib/work-tracking/index.js +108 -8
  145. package/package.json +1 -1
  146. package/skills-templates/bug-mode/SKILL.md +43 -1
  147. package/skills-templates/chore-mode/SKILL.md +40 -1
  148. package/skills-templates/design-system-selection/SKILL.md +273 -0
  149. package/skills-templates/epic-planning/SKILL.md +14 -0
  150. package/skills-templates/feature-planning/SKILL.md +90 -1
  151. package/skills-templates/production-mode/SKILL.md +20 -0
  152. package/skills-templates/simple-improvement/SKILL.md +39 -2
  153. package/skills-templates/speed-mode/SKILL.md +10 -15
  154. package/skills-templates/stable-mode/SKILL.md +47 -0
  155. package/apps/dashboard/README.md +0 -36
  156. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -446
  157. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  158. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -280
  159. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  160. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -525
  161. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  162. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  163. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  164. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  165. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  166. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  167. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  168. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  169. package/apps/dashboard/app/api/tests/route.ts +0 -9
  170. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  171. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  172. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  173. package/apps/dashboard/app/api/usage/route.ts +0 -17
  174. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  175. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  176. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  177. package/apps/dashboard/app/api/work/[id]/route.ts +0 -35
  178. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -63
  179. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  180. package/apps/dashboard/app/layout.tsx +0 -55
  181. package/apps/dashboard/components/UpgradeBanner.tsx +0 -30
  182. package/apps/dashboard/electron/ipc-handlers.js +0 -1026
  183. package/apps/dashboard/electron/main.js +0 -2306
  184. package/apps/dashboard/electron/preload.js +0 -125
  185. package/apps/dashboard/electron/session-manager.js +0 -163
  186. package/apps/dashboard/electron-builder.config.js +0 -357
  187. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  188. package/apps/dashboard/lib/backlog-parser.ts +0 -50
  189. package/apps/dashboard/lib/claude-process-manager.ts +0 -529
  190. package/apps/dashboard/lib/db-bridge.ts +0 -283
  191. package/apps/dashboard/lib/prototypes.ts +0 -202
  192. package/apps/dashboard/lib/test-results-db.ts +0 -307
  193. package/apps/dashboard/lib/tests.ts +0 -282
  194. package/apps/dashboard/next.config.js +0 -66
  195. package/apps/dashboard/postcss.config.mjs +0 -7
  196. package/apps/dashboard/public/bug-icon.svg +0 -9
  197. package/apps/dashboard/public/buoy-icon.svg +0 -9
  198. package/apps/dashboard/public/file.svg +0 -1
  199. package/apps/dashboard/public/globe.svg +0 -1
  200. package/apps/dashboard/public/in-flight-seagull.svg +0 -9
  201. package/apps/dashboard/public/next.svg +0 -1
  202. package/apps/dashboard/public/pier-icon.svg +0 -14
  203. package/apps/dashboard/public/star-icon.svg +0 -9
  204. package/apps/dashboard/public/vercel.svg +0 -1
  205. package/apps/dashboard/public/window.svg +0 -1
  206. package/apps/dashboard/public/wrench-icon.svg +0 -9
  207. package/apps/dashboard/scripts/download-node.js +0 -104
  208. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
@@ -1,4 +1,3 @@
1
- 'use client';
2
1
 
3
2
  import { createContext, useContext, useState, useEffect, useRef, useMemo, type ReactNode } from 'react';
4
3
 
@@ -1,8 +1,10 @@
1
- 'use client';
2
1
 
3
- import { createContext, useContext, useState, useEffect, useCallback, useMemo, 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';
5
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';
6
8
 
7
9
  const FREE_WEEKLY_LIMIT = 20;
8
10
  const API_BASE = 'https://jettypod-update-server.spangbaryn2.workers.dev';
@@ -32,31 +34,43 @@ export function UsageProvider({ children }: { children: ReactNode }) {
32
34
  loading: true,
33
35
  });
34
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
+
35
47
  const fetchUsage = useCallback(async () => {
36
- // Get plan from auth (if Electron)
48
+ // Get plan from auth (if Tauri)
37
49
  let plan = 'free';
38
50
  try {
39
- if (window.electronAPI?.isElectron) {
40
- const status = await window.electronAPI.auth.getStatus();
51
+ if (isTauri()) {
52
+ const status = await auth.getStatus();
41
53
  if (status.authenticated && status.user) {
42
54
  plan = status.user.plan || 'free';
43
55
  }
44
56
 
45
57
  // Check server-side plan — the local JWT may be stale
46
- if (plan === 'free') {
47
- const token = await window.electronAPI.auth.getToken();
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();
48
61
  if (token) {
49
62
  try {
50
63
  const res = await fetch(`${API_BASE}/auth/me`, {
51
64
  headers: { 'Authorization': `Bearer ${token}` },
52
65
  });
66
+ lastServerCheckRef.current = Date.now();
53
67
  if (res.ok) {
54
68
  const data = await res.json() as { user: { plan: string; email: string }; token?: string };
55
69
  if (data.user.plan !== 'free') {
56
70
  plan = data.user.plan;
57
71
  // Server issued a fresh JWT with updated plan — save it locally
58
72
  if (data.token) {
59
- await window.electronAPI.auth.saveToken(data.token, data.user);
73
+ await auth.saveToken(data.token, data.user);
60
74
  }
61
75
  }
62
76
  }
@@ -83,51 +97,68 @@ export function UsageProvider({ children }: { children: ReactNode }) {
83
97
  return;
84
98
  }
85
99
 
86
- // Free plan — count from local DB via API route
100
+ // Free plan — count active sessions via Tauri IPC
87
101
  try {
88
- const res = await fetch('/api/usage');
89
- console.log('[usage] UsageContext fetch status:', res.status);
90
- if (!res.ok) {
91
- console.error('[usage] UsageContext fetch not ok:', res.status, res.statusText);
92
- setState(prev => ({ ...prev, allowed: true, loading: false }));
93
- return;
94
- }
95
- const usage = await res.json();
96
- 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);
97
105
  setState({
98
- used: typeof usage.used === 'number' ? usage.used : 0,
99
- limit: typeof usage.limit === 'number' ? usage.limit : FREE_WEEKLY_LIMIT,
100
- remaining: typeof usage.remaining === 'number' ? usage.remaining : FREE_WEEKLY_LIMIT,
101
- allowed: typeof usage.allowed === 'boolean' ? usage.allowed : true,
106
+ used,
107
+ limit: FREE_WEEKLY_LIMIT,
108
+ remaining,
109
+ allowed: remaining > 0,
102
110
  plan,
103
111
  loading: false,
104
112
  });
105
113
  } catch (err) {
106
- console.error('[usage] UsageContext fetch error:', err);
114
+ console.error('[usage] UsageContext IPC error:', err);
107
115
  setState(prev => ({ ...prev, allowed: true, loading: false }));
108
116
  }
109
117
  }, []);
110
118
 
111
- // Fetch on mount
119
+ // Fetch on mount (only after project is loaded)
112
120
  useEffect(() => {
113
- fetchUsage();
114
- }, [fetchUsage]);
121
+ if (projectLoaded) fetchUsage();
122
+ }, [projectLoaded, fetchUsage]);
115
123
 
116
124
  // Refresh on window focus
117
125
  useEffect(() => {
126
+ if (!projectLoaded) return;
118
127
  const handleFocus = () => fetchUsage();
119
128
  window.addEventListener('focus', handleFocus);
120
129
  return () => window.removeEventListener('focus', handleFocus);
121
- }, [fetchUsage]);
130
+ }, [projectLoaded, fetchUsage]);
122
131
 
123
- // 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);
124
137
  const handleWsMessage = useCallback((message: WebSocketMessage) => {
125
138
  if (message.type === 'db_change') {
126
- 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);
127
151
  }
128
- }, [fetchUsage]);
152
+ }, [fetchUsage, state.plan]);
129
153
 
130
- useWebSocket({ url: getWebSocketUrl(), onMessage: handleWsMessage });
154
+ useWebSocket({ url: projectLoaded ? getWebSocketUrl() : '', onMessage: handleWsMessage });
155
+
156
+ // Cleanup debounce timer
157
+ useEffect(() => {
158
+ return () => {
159
+ if (usageDebounceRef.current) clearTimeout(usageDebounceRef.current);
160
+ };
161
+ }, []);
131
162
 
132
163
  const value = useMemo(() => ({ ...state, refresh: fetchUsage }), [state, fetchUsage]);
133
164
 
@@ -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;
@@ -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
  }
@@ -0,0 +1,73 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>JettyPod</title>
7
+ <link rel="preload" href="/fonts/Satoshi-Variable.woff2" as="font" type="font/woff2" crossorigin />
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
9
+ <link rel="preconnect" href="ws://localhost:3456" />
10
+ <link rel="dns-prefetch" href="ws://localhost:3456" />
11
+ </head>
12
+ <body class="antialiased">
13
+ <div id="root">
14
+ <!-- Static skeleton — visible in <200ms before React loads, replaced on mount -->
15
+ <style>
16
+ @keyframes sk-pulse { 0%,100% { opacity: 1 } 50% { opacity: .4 } }
17
+ .sk { animation: sk-pulse 1.5s ease-in-out infinite; }
18
+ @media (prefers-color-scheme: dark) {
19
+ .sk-bg { background: #18181b; }
20
+ .sk-nav { background: #18181b; border-color: #27272a; }
21
+ .sk-col { background: #18181b; }
22
+ .sk-el { background: #27272a; }
23
+ }
24
+ </style>
25
+ <div style="display:flex;flex-direction:column;height:100vh;background:#fafafa" class="sk-bg">
26
+ <!-- Nav skeleton -->
27
+ <header style="padding:20px;border-bottom:1px solid #e4e4e7;background:#fff;flex-shrink:0" class="sk-nav">
28
+ <div style="display:flex;align-items:center;gap:16px">
29
+ <div style="width:36px;height:36px;border-radius:50%;background:#e4e4e7" class="sk-el sk"></div>
30
+ <div style="width:100px;height:20px;border-radius:6px;background:#e4e4e7" class="sk-el sk"></div>
31
+ <div style="width:60px;height:20px;border-radius:6px;background:#e4e4e7" class="sk-el sk"></div>
32
+ <div style="width:48px;height:20px;border-radius:6px;background:#e4e4e7" class="sk-el sk"></div>
33
+ </div>
34
+ </header>
35
+ <!-- Kanban skeleton -->
36
+ <div style="flex:1;max-width:80rem;width:100%;margin:0 auto;padding:16px;display:flex;gap:16px;min-height:0">
37
+ <!-- Backlog column -->
38
+ <div style="flex:1;max-width:600px;display:flex;flex-direction:column;min-height:0">
39
+ <div style="background:#f4f4f5;border-radius:12px;padding:16px;flex:1;min-height:0" class="sk-col">
40
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
41
+ <div style="width:96px;height:24px;border-radius:6px;background:#e4e4e7" class="sk-el sk"></div>
42
+ <div style="width:40px;height:24px;border-radius:12px;background:#e4e4e7" class="sk-el sk"></div>
43
+ </div>
44
+ <div style="display:flex;flex-direction:column;gap:12px">
45
+ <div style="height:80px;border-radius:12px;background:#e4e4e7" class="sk-el sk"></div>
46
+ <div style="height:80px;border-radius:12px;background:#e4e4e7" class="sk-el sk"></div>
47
+ <div style="height:80px;border-radius:12px;background:#e4e4e7" class="sk-el sk"></div>
48
+ <div style="height:80px;border-radius:12px;background:#e4e4e7" class="sk-el sk"></div>
49
+ <div style="height:80px;border-radius:12px;background:#e4e4e7" class="sk-el sk"></div>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ <!-- Done column -->
54
+ <div style="flex:1;max-width:600px;display:flex;flex-direction:column;min-height:0">
55
+ <div style="background:#f4f4f5;border-radius:12px;padding:16px;flex:1;min-height:0" class="sk-col">
56
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
57
+ <div style="width:64px;height:24px;border-radius:6px;background:#e4e4e7" class="sk-el sk"></div>
58
+ <div style="width:40px;height:24px;border-radius:12px;background:#e4e4e7" class="sk-el sk"></div>
59
+ </div>
60
+ <div style="display:flex;flex-direction:column;gap:12px">
61
+ <div style="height:64px;border-radius:12px;background:#e4e4e7" class="sk-el sk"></div>
62
+ <div style="height:64px;border-radius:12px;background:#e4e4e7" class="sk-el sk"></div>
63
+ <div style="height:64px;border-radius:12px;background:#e4e4e7" class="sk-el sk"></div>
64
+ </div>
65
+ </div>
66
+ </div>
67
+ </div>
68
+ </div>
69
+ </div>
70
+ <div id="tooltip-root" style="position: relative; z-index: 99999"></div>
71
+ <script type="module" src="/src/main.tsx"></script>
72
+ </body>
73
+ </html>