Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
70589a2
Merge pull request #4 from yelabb/dev
yelabb Feb 1, 2026
9a9fd6b
feat: implement UI state slice to manage modal interactions and preve…
yelabb Feb 1, 2026
4518c34
feat: add filtering for AI generated decoders in DecoderSelector comp…
yelabb Feb 1, 2026
c2fe37a
fix: simplify parameter usage in parseJsonWithRepair function
yelabb Feb 1, 2026
0ce48d7
feat: implement custom decoder persistence with localStorage
yelabb Feb 1, 2026
f04ea61
feat: implement code-based TensorFlow.js model execution and caching
yelabb Feb 1, 2026
761c6db
fix: enhance error handling and logging in executeCodeBasedTFJSDecode…
yelabb Feb 1, 2026
b7b815c
fix: move Name input field to maintain consistent layout in AddDecode…
yelabb Feb 1, 2026
a013668
fix: update TensorFlow.js usage in code generation prompts and model …
yelabb Feb 1, 2026
130faf3
Refactor Code Editor and Decoder Execution for Custom JavaScript Deco…
yelabb Feb 1, 2026
c8ab382
feat: implement caching and execution for custom TensorFlow.js models
yelabb Feb 1, 2026
fba1a10
fix: remove failed model tracking and implement caching for JS decode…
yelabb Feb 1, 2026
738b452
refactor: update AddDecoderModal and CodeEditor for TensorFlow.js int…
yelabb Feb 1, 2026
ba1ca5a
fix: ensure tf.train namespace is included for custom TensorFlow.js code
yelabb Feb 1, 2026
2ec04fe
feat: implement model creation from custom code in Web Worker for Ten…
yelabb Feb 1, 2026
b6a1903
fix: update model generation requirements to return a compiled model …
yelabb Feb 1, 2026
6b3d52a
Update hardware list
yelabb Feb 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ function App() {
const isConnected = useStore((state) => state.isConnected);
const dataSource = useStore((state) => state.dataSource);

// Check if a modal is blocking navigation
const isModalBlocking = useStore((state) => state.isModalBlocking);

// Check ESP-EEG connection state
const { connectionStatus: espConnectionStatus } = useESPEEG();

Expand All @@ -35,25 +38,37 @@ function App() {

// PhantomLink disconnection handling
useEffect(() => {
// Don't navigate away if a modal is open (e.g., Add Decoder modal with AI generation)
if (isModalBlocking()) {
wasConnectedRef.current = isConnected;
return;
}

if (wasConnectedRef.current && !isConnected && currentScreen === 'dashboard') {
// Use setTimeout to avoid setState during render cycle
setTimeout(() => setCurrentScreen('welcome'), 0);
}
wasConnectedRef.current = isConnected;
}, [isConnected, currentScreen]);
}, [isConnected, currentScreen, isModalBlocking]);

// ESP-EEG disconnection handling
useEffect(() => {
const isESPConnected = espConnectionStatus === 'connected';
const isOnESPScreen = currentScreen === 'electrode-placement' ||
(currentScreen === 'dashboard' && dataSource?.type === 'esp-eeg');

// Don't navigate away if a modal is open
if (isModalBlocking()) {
wasESPConnectedRef.current = isESPConnected;
return;
}

if (wasESPConnectedRef.current && !isESPConnected && isOnESPScreen) {
// Use setTimeout to avoid setState during render cycle
setTimeout(() => setCurrentScreen('welcome'), 0);
}
wasESPConnectedRef.current = isESPConnected;
}, [espConnectionStatus, currentScreen, dataSource?.type]);
}, [espConnectionStatus, currentScreen, dataSource?.type, isModalBlocking]);

return (
<div className="w-screen h-screen overflow-hidden bg-black relative">
Expand Down
23 changes: 20 additions & 3 deletions src/components/AddDecoderModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* - Code: Write JavaScript to build/train models with TensorFlow.js
*/

import { memo, useState, useCallback } from 'react';
import { memo, useState, useCallback, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useStore } from '../store';
import { registerCustomDecoder } from '../decoders';
Expand All @@ -19,8 +19,12 @@ interface AddDecoderModalProps {
onClose: () => void;
}

const MODAL_ID = 'add-decoder-modal';

export const AddDecoderModal = memo(function AddDecoderModal({ isOpen, onClose }: AddDecoderModalProps) {
const registerDecoder = useStore((state) => state.registerDecoder);
const openModal = useStore((state) => state.openModal);
const closeModal = useStore((state) => state.closeModal);

const [sourceType, setSourceType] = useState<'url' | 'code'>('url');
const [name, setName] = useState('');
Expand All @@ -32,6 +36,19 @@ export const AddDecoderModal = memo(function AddDecoderModal({ isOpen, onClose }
const [showAIPrompt, setShowAIPrompt] = useState(false);
const [aiPrompt, setAiPrompt] = useState('');

// Register modal state with store to prevent auto-navigation during AI generation
useEffect(() => {
if (isOpen) {
openModal(MODAL_ID);
} else {
closeModal();
}
return () => {
// Cleanup on unmount
closeModal();
};

Copilot AI Feb 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cleanup function unconditionally calls closeModal() on unmount, but this could interfere with modal state if the component unmounts for reasons other than closing the modal. If the parent component re-renders and causes AddDecoderModal to unmount/remount while isOpen is still true, this would clear the modal state unexpectedly. Consider only calling closeModal in the cleanup if the modal was actually open, or rely on the else branch (line 44) to handle the close case.

Suggested change
} else {
closeModal();
}
return () => {
// Cleanup on unmount
closeModal();
};
// Only register a cleanup when the modal was actually opened
return () => {
closeModal();
};
}
// If not open, ensure modal state is closed, but no cleanup is needed
closeModal();
return;

Copilot uses AI. Check for mistakes.
}, [isOpen, openModal, closeModal]);

const handleSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault();
setError('');
Expand Down Expand Up @@ -122,8 +139,8 @@ export const AddDecoderModal = memo(function AddDecoderModal({ isOpen, onClose }
if (!description.trim()) {
setDescription(result.explanation);
}
setShowAIPrompt(false);
setAiPrompt('');
// Keep the prompt visible and preserve the text for iteration
// User can modify and regenerate if needed
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to generate code';
setError(errorMsg);
Expand Down
15 changes: 14 additions & 1 deletion src/components/DecoderSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,14 @@ export const DecoderSelector = memo(function DecoderSelector() {

// Group decoders by type and source
const builtinDecoders = availableDecoders.filter(d =>
d.type === 'tfjs' && (!d.source || d.source.type === 'builtin')
d.type === 'tfjs' && (!d.source || d.source.type === 'builtin') && !d.code
);
const customDecoders = availableDecoders.filter(d =>
d.source?.type === 'url' || d.source?.type === 'local'
);
const codeDecoders = availableDecoders.filter(d =>
d.type === 'tfjs' && d.code

Copilot AI Feb 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI-generated decoders created with type 'tfjs' and a 'code' property will not work correctly. The executeTFJSDecoder function (lines 85-159 in executeDecoder.ts) expects decoders to have a tfjsModelType property and routes inference to the Web Worker based on that. It does not handle the 'code' property at all. When executeDecoder (line 168) routes 'tfjs' type decoders to executeTFJSDecoder, decoders with only a 'code' property will fail or fall back to default behavior. Additionally, initModel in decoders/index.ts doesn't handle the 'code' property for model initialization. The code generation feature appears to create decoders that won't execute properly.

Suggested change
d.type === 'tfjs' && d.code
d.type === 'javascript' && d.code

Copilot uses AI. Check for mistakes.
);
const jsDecoders = availableDecoders.filter(d => d.type === 'javascript');

return (
Expand Down Expand Up @@ -149,6 +152,16 @@ export const DecoderSelector = memo(function DecoderSelector() {
))}
</optgroup>
)}

{codeDecoders.length > 0 && (
<optgroup label="✨ AI Generated">
{codeDecoders.map(decoder => (
<option key={decoder.id} value={decoder.id}>
{decoder.name}
</option>
))}
</optgroup>
)}

{jsDecoders.length > 0 && (
<optgroup label="📜 Baselines">
Expand Down
81 changes: 81 additions & 0 deletions src/decoders/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import { tfjsDecoders } from './tfjsDecoders';
import { tfWorker, getWorkerModelType } from './tfWorkerManager';
import type { Decoder, TFJSModelType, DecoderSource } from '../types/decoders';

// Storage key for persisting custom decoders
const CUSTOM_DECODERS_KEY = 'phantomloop-custom-decoders-v1';

// All available decoders
export const allDecoders: Decoder[] = [
...tfjsDecoders, // Neural network decoders (recommended)
Expand All @@ -26,6 +29,56 @@ for (const decoder of allDecoders) {
decoderMap.set(decoder.id, decoder);
}

/**
* Load persisted custom decoders from localStorage
*/
function loadPersistedDecoders(): void {
try {
const stored = localStorage.getItem(CUSTOM_DECODERS_KEY);
if (!stored) return;

const decoders: Decoder[] = JSON.parse(stored);
console.log(`[Decoder] Loading ${decoders.length} persisted custom decoder(s)`);

for (const decoder of decoders) {
// Don't overwrite builtin decoders
if (!decoderMap.has(decoder.id)) {
decoderMap.set(decoder.id, decoder);
allDecoders.push(decoder);
console.log(`[Decoder] Restored: ${decoder.name}`);
}
}
} catch (error) {
console.warn('[Decoder] Failed to load persisted decoders:', error);
// Clear corrupted data
localStorage.removeItem(CUSTOM_DECODERS_KEY);
}
}

/**
* Save custom decoders to localStorage
*/
function persistCustomDecoders(): void {
try {
// Get all custom decoders (those with code or custom source)
const customDecoders = allDecoders.filter(d =>
d.code || d.source?.type === 'url' || d.source?.type === 'local'
);

if (customDecoders.length === 0) {
localStorage.removeItem(CUSTOM_DECODERS_KEY);
} else {
localStorage.setItem(CUSTOM_DECODERS_KEY, JSON.stringify(customDecoders));
console.log(`[Decoder] Persisted ${customDecoders.length} custom decoder(s)`);
}
} catch (error) {
console.warn('[Decoder] Failed to persist decoders:', error);
}
}

// Load persisted decoders on module initialization
loadPersistedDecoders();

/**
* Register a custom decoder at runtime
*/
Expand All @@ -42,6 +95,34 @@ export function registerCustomDecoder(decoder: Decoder): void {
allDecoders.push(decoder);
}
console.log(`[Decoder] Registered: ${decoder.name} (${decoder.source?.type || decoder.type})`);

// Persist to localStorage
persistCustomDecoders();
}

/**
* Remove a custom decoder
*/
export function removeCustomDecoder(decoderId: string): boolean {
const decoder = decoderMap.get(decoderId);
if (!decoder) return false;

// Don't allow removing builtin decoders
const isCustom = decoder.code || decoder.source?.type === 'url' || decoder.source?.type === 'local';
if (!isCustom) {
console.warn(`[Decoder] Cannot remove builtin decoder: ${decoderId}`);
return false;
}

decoderMap.delete(decoderId);
const index = allDecoders.findIndex(d => d.id === decoderId);
if (index >= 0) {
allDecoders.splice(index, 1);
}

console.log(`[Decoder] Removed: ${decoder.name}`);
persistCustomDecoders();
return true;
}

/**
Expand Down
6 changes: 5 additions & 1 deletion src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,22 @@ import type { UnifiedStreamSlice } from './slices/unifiedStreamSlice';
import type { DecoderSlice } from './slices/decoderSlice';
import type { MetricsSlice } from './slices/metricsSlice';
import type { ElectrodeSlice } from './slices/electrodeSlice';
import type { UISlice } from './slices/uiSlice';
import { createConnectionSlice } from './slices/connectionSlice';
import { createStreamSlice } from './slices/streamSlice';
import { createUnifiedStreamSlice } from './slices/unifiedStreamSlice';
import { createDecoderSlice } from './slices/decoderSlice';
import { createMetricsSlice } from './slices/metricsSlice';
import { createElectrodeSlice } from './slices/electrodeSlice';
import { createUISlice } from './slices/uiSlice';

export type StoreState = ConnectionSlice &
StreamSlice &
UnifiedStreamSlice &
DecoderSlice &
MetricsSlice &
ElectrodeSlice;
ElectrodeSlice &
UISlice;

export const useStore = create<StoreState>()((...a) => ({
...createConnectionSlice(...a),
Expand All @@ -28,4 +31,5 @@ export const useStore = create<StoreState>()((...a) => ({
...createDecoderSlice(...a),
...createMetricsSlice(...a),
...createElectrodeSlice(...a),
...createUISlice(...a),
}));
46 changes: 46 additions & 0 deletions src/store/slices/uiSlice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* UI State Slice
*
* Manages global UI state like modals, preventing auto-navigation
* when critical modals are open.
*/

import type { StateCreator } from 'zustand';

export interface UISlice {
// Modal state - blocks auto-navigation when a critical modal is open
activeModal: string | null;

// Actions
openModal: (modalId: string) => void;
closeModal: () => void;

// Computed - check if navigation should be blocked
isModalBlocking: () => boolean;
}

export const createUISlice: StateCreator<
UISlice,
[],
[],
UISlice
> = (set, get) => ({
activeModal: null,

openModal: (modalId: string) => {
set({ activeModal: modalId });
console.log(`[UI] Modal opened: ${modalId}`);
},

closeModal: () => {
const current = get().activeModal;
set({ activeModal: null });
if (current) {
console.log(`[UI] Modal closed: ${current}`);
}
},

isModalBlocking: () => {
return get().activeModal !== null;
},
});
Loading