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.
- package/.env +4 -3
- package/Cargo.lock +6450 -0
- package/Cargo.toml +35 -0
- package/README.md +5 -1
- package/TAURI-MIGRATION-PLAN.md +840 -0
- package/apps/dashboard/app/connect-claude/page.tsx +5 -6
- package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
- package/apps/dashboard/app/demo/gates/page.tsx +43 -45
- package/apps/dashboard/app/design-system/page.tsx +868 -0
- package/apps/dashboard/app/globals.css +80 -4
- package/apps/dashboard/app/install-claude/page.tsx +4 -6
- package/apps/dashboard/app/login/page.tsx +72 -54
- package/apps/dashboard/app/page.tsx +101 -48
- package/apps/dashboard/app/settings/page.tsx +61 -13
- package/apps/dashboard/app/signup/page.tsx +242 -0
- package/apps/dashboard/app/subscribe/page.tsx +0 -2
- package/apps/dashboard/app/tests/page.tsx +37 -4
- package/apps/dashboard/app/welcome/page.tsx +13 -16
- package/apps/dashboard/app/work/[id]/page.tsx +117 -118
- package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
- package/apps/dashboard/components/AppShell.tsx +92 -85
- package/apps/dashboard/components/CardMenu.tsx +45 -12
- package/apps/dashboard/components/ClaudePanel.tsx +771 -850
- package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
- package/apps/dashboard/components/CopyableId.tsx +3 -4
- package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
- package/apps/dashboard/components/DragContext.tsx +134 -63
- package/apps/dashboard/components/DraggableCard.tsx +3 -5
- package/apps/dashboard/components/DropZone.tsx +6 -7
- package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
- package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
- package/apps/dashboard/components/EditableTitle.tsx +26 -7
- package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
- package/apps/dashboard/components/EpicGroup.tsx +359 -0
- package/apps/dashboard/components/GateCard.tsx +79 -17
- package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
- package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
- package/apps/dashboard/components/JettyLoader.tsx +37 -0
- package/apps/dashboard/components/KanbanBoard.tsx +368 -958
- package/apps/dashboard/components/KanbanCard.tsx +740 -0
- package/apps/dashboard/components/LazyCard.tsx +62 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
- package/apps/dashboard/components/MainNav.tsx +38 -73
- package/apps/dashboard/components/MessageBlock.tsx +468 -0
- package/apps/dashboard/components/ModeStartCard.tsx +15 -16
- package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
- package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
- package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
- package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
- package/apps/dashboard/components/ReviewFooter.tsx +139 -0
- package/apps/dashboard/components/SessionList.tsx +19 -19
- package/apps/dashboard/components/SubscribeContent.tsx +91 -47
- package/apps/dashboard/components/TestTree.tsx +16 -16
- package/apps/dashboard/components/TipCard.tsx +16 -17
- package/apps/dashboard/components/Toast.tsx +5 -6
- package/apps/dashboard/components/TypeIcon.tsx +55 -0
- package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
- package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
- package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
- package/apps/dashboard/components/WorkItemTree.tsx +11 -32
- package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
- package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
- package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
- package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
- package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
- package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
- package/apps/dashboard/components/ui/Button.tsx +104 -0
- package/apps/dashboard/components/ui/Input.tsx +78 -0
- package/apps/dashboard/components.json +1 -1
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
- package/apps/dashboard/contexts/UsageContext.tsx +87 -32
- package/apps/dashboard/dev.sh +35 -0
- package/apps/dashboard/eslint.config.mjs +9 -9
- package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
- package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
- package/apps/dashboard/hooks/useWebSocket.ts +138 -83
- package/apps/dashboard/index.html +73 -0
- package/apps/dashboard/lib/constants.ts +43 -0
- package/apps/dashboard/lib/data-bridge.ts +722 -0
- package/apps/dashboard/lib/db.ts +69 -1265
- package/apps/dashboard/lib/environment-config.ts +173 -0
- package/apps/dashboard/lib/environment-verification.ts +119 -0
- package/apps/dashboard/lib/kanban-utils.ts +270 -0
- package/apps/dashboard/lib/proof-run.ts +495 -0
- package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
- package/apps/dashboard/lib/run-migrations.js +27 -2
- package/apps/dashboard/lib/service-recovery.ts +326 -0
- package/apps/dashboard/lib/session-state-machine.ts +1 -0
- package/apps/dashboard/lib/session-state-utils.ts +0 -164
- package/apps/dashboard/lib/session-stream-manager.ts +308 -134
- package/apps/dashboard/lib/shadows.ts +7 -0
- package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
- package/apps/dashboard/lib/tauri-bridge.ts +102 -0
- package/apps/dashboard/lib/tauri.ts +106 -0
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next-env.d.ts +1 -1
- package/apps/dashboard/package.json +21 -32
- package/apps/dashboard/public/bug-icon.png +0 -0
- package/apps/dashboard/public/buoy-icon.png +0 -0
- package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
- package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
- package/apps/dashboard/public/in-flight-seagull.png +0 -0
- package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
- package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
- package/apps/dashboard/public/jettypod_logo.png +0 -0
- package/apps/dashboard/public/pier-icon.png +0 -0
- package/apps/dashboard/public/star-icon.png +0 -0
- package/apps/dashboard/public/wrench-icon.png +0 -0
- package/apps/dashboard/scripts/tauri-build.js +228 -0
- package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
- package/apps/dashboard/scripts/ws-server.js +191 -0
- package/apps/dashboard/src/main.tsx +12 -0
- package/apps/dashboard/src/router.tsx +107 -0
- package/apps/dashboard/src/vite-env.d.ts +1 -0
- package/apps/dashboard/tsconfig.json +7 -12
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
- package/apps/dashboard/vite.config.ts +33 -0
- package/apps/update-server/src/index.ts +228 -80
- package/claude-hooks/global-guardrails.js +14 -13
- package/crates/jettypod-cli/Cargo.toml +19 -0
- package/crates/jettypod-cli/src/commands.rs +1249 -0
- package/crates/jettypod-cli/src/main.rs +595 -0
- package/crates/jettypod-core/Cargo.toml +26 -0
- package/crates/jettypod-core/build.rs +98 -0
- package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
- package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
- package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
- package/crates/jettypod-core/src/auth.rs +294 -0
- package/crates/jettypod-core/src/config.rs +397 -0
- package/crates/jettypod-core/src/db/mod.rs +507 -0
- package/crates/jettypod-core/src/db/recovery.rs +114 -0
- package/crates/jettypod-core/src/db/startup.rs +101 -0
- package/crates/jettypod-core/src/db/validate.rs +149 -0
- package/crates/jettypod-core/src/error.rs +76 -0
- package/crates/jettypod-core/src/git.rs +458 -0
- package/crates/jettypod-core/src/lib.rs +20 -0
- package/crates/jettypod-core/src/sessions.rs +625 -0
- package/crates/jettypod-core/src/skills.rs +556 -0
- package/crates/jettypod-core/src/work.rs +1086 -0
- package/crates/jettypod-core/src/worktree.rs +628 -0
- package/crates/jettypod-core/src/ws.rs +767 -0
- package/cucumber-test.cjs +6 -0
- package/cucumber.js +9 -3
- package/docs/COMMAND_REFERENCE.md +34 -0
- package/hooks/post-checkout +32 -75
- package/hooks/post-merge +111 -10
- package/jest.setup.js +1 -0
- package/jettypod.js +145 -116
- package/lib/bdd-preflight.js +96 -0
- package/lib/chore-taxonomy.js +33 -10
- package/lib/database.js +36 -16
- package/lib/db-watcher.js +1 -1
- package/lib/git-hooks/pre-commit +1 -1
- package/lib/jettypod-backup.js +27 -4
- package/lib/merge-lock.js +111 -253
- package/lib/migrations/027-plan-at-creation-column.js +3 -1
- package/lib/migrations/029-remove-autoincrement.js +307 -0
- package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
- package/lib/migrations/030-rejection-round-columns.js +54 -0
- package/lib/migrations/031-session-isolation-index.js +17 -0
- package/lib/migrations/index.js +47 -4
- package/lib/schema.js +10 -5
- package/lib/seed-onboarding.js +1 -1
- package/lib/update-command/index.js +9 -175
- package/lib/work-commands/index.js +144 -19
- package/lib/work-tracking/index.js +148 -27
- package/lib/worktree-diagnostics.js +16 -16
- package/lib/worktree-facade.js +1 -1
- package/lib/worktree-manager.js +8 -8
- package/lib/worktree-reconciler.js +5 -5
- package/package.json +9 -2
- package/scripts/ndjson-to-cucumber-json.js +152 -0
- package/scripts/postinstall.js +25 -0
- package/skills-templates/bug-mode/SKILL.md +79 -20
- package/skills-templates/bug-planning/SKILL.md +25 -29
- package/skills-templates/chore-mode/SKILL.md +171 -69
- package/skills-templates/chore-mode/verification.js +51 -10
- package/skills-templates/chore-planning/SKILL.md +47 -18
- package/skills-templates/design-system-selection/SKILL.md +273 -0
- package/skills-templates/epic-planning/SKILL.md +82 -48
- package/skills-templates/external-transition/SKILL.md +47 -47
- package/skills-templates/feature-planning/SKILL.md +173 -74
- package/skills-templates/production-mode/SKILL.md +69 -49
- package/skills-templates/request-routing/SKILL.md +4 -4
- package/skills-templates/simple-improvement/SKILL.md +74 -29
- package/skills-templates/speed-mode/SKILL.md +217 -141
- package/skills-templates/stable-mode/SKILL.md +148 -89
- package/apps/dashboard/README.md +0 -36
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
- package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
- package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
- package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
- package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
- package/apps/dashboard/app/api/kanban/route.ts +0 -15
- package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
- package/apps/dashboard/app/api/settings/general/route.ts +0 -21
- package/apps/dashboard/app/api/tests/route.ts +0 -9
- package/apps/dashboard/app/api/tests/run/route.ts +0 -82
- package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
- package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
- package/apps/dashboard/app/api/usage/route.ts +0 -17
- package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
- package/apps/dashboard/app/layout.tsx +0 -43
- package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
- package/apps/dashboard/electron/ipc-handlers.js +0 -1028
- package/apps/dashboard/electron/main.js +0 -2124
- package/apps/dashboard/electron/preload.js +0 -123
- package/apps/dashboard/electron/session-manager.js +0 -141
- package/apps/dashboard/electron-builder.config.js +0 -357
- package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
- package/apps/dashboard/lib/claude-process-manager.ts +0 -492
- package/apps/dashboard/lib/db-bridge.ts +0 -282
- package/apps/dashboard/lib/prototypes.ts +0 -202
- package/apps/dashboard/lib/test-results-db.ts +0 -307
- package/apps/dashboard/lib/tests.ts +0 -282
- package/apps/dashboard/next.config.js +0 -50
- package/apps/dashboard/postcss.config.mjs +0 -7
- package/apps/dashboard/public/file.svg +0 -1
- package/apps/dashboard/public/globe.svg +0 -1
- package/apps/dashboard/public/next.svg +0 -1
- package/apps/dashboard/public/vercel.svg +0 -1
- package/apps/dashboard/public/window.svg +0 -1
- package/apps/dashboard/scripts/download-node.js +0 -104
- package/apps/dashboard/scripts/upload-to-r2.js +0 -89
- 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={
|
|
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
|
-
|
|
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
|
|
48
|
+
// Get plan from auth (if Tauri)
|
|
35
49
|
let plan = 'free';
|
|
36
50
|
try {
|
|
37
|
-
if (
|
|
38
|
-
const status = await
|
|
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
|
|
100
|
+
// Free plan — count active sessions via Tauri IPC
|
|
61
101
|
try {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
73
|
-
limit:
|
|
74
|
-
remaining
|
|
75
|
-
allowed:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
156
|
+
// Cleanup debounce timer
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
return () => {
|
|
159
|
+
if (usageDebounceRef.current) clearTimeout(usageDebounceRef.current);
|
|
160
|
+
};
|
|
161
|
+
}, []);
|
|
107
162
|
|
|
108
|
-
|
|
163
|
+
const value = useMemo(() => ({ ...state, refresh: fetchUsage }), [state, fetchUsage]);
|
|
109
164
|
|
|
110
165
|
return (
|
|
111
|
-
<UsageContext.Provider value={
|
|
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
|
-
|
|
11
|
-
".next/**",
|
|
12
|
-
"out/**",
|
|
5
|
+
"dist/**",
|
|
13
6
|
"build/**",
|
|
14
|
-
"
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
94
|
+
ws.onerror = () => {
|
|
95
|
+
ws.close();
|
|
96
|
+
};
|
|
76
97
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
setIsReconnecting(false);
|
|
80
|
-
setReconnectionFailed(true);
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
98
|
+
sharedWs = ws;
|
|
99
|
+
}
|
|
83
100
|
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
};
|
|
109
|
+
return () => {
|
|
110
|
+
subscribers.delete(callback);
|
|
99
111
|
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
144
|
+
onMessageRef.current = onMessage;
|
|
145
|
+
}, [onMessage]);
|
|
112
146
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
156
|
+
stateListeners.add(listener);
|
|
157
|
+
return () => { stateListeners.delete(listener); };
|
|
158
|
+
}, []);
|
|
122
159
|
|
|
123
|
-
|
|
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
|
}
|