From 144f5365c1f21efb40b92427712fc5c6fc75aa70 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 27 Nov 2025 23:57:26 -0800 Subject: [PATCH] feat: move Mycelial Intelligence to permanent UI bar + fix ImageGen RunPod API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mycelial Intelligence UI refactor: - Created permanent floating bar at top of screen (MycelialIntelligenceBar.tsx) - Bar stays fixed and doesn't zoom with canvas - Collapses when clicking outside - Removed from toolbar tool menu - Added deprecated shape stub for backwards compatibility with old boards - ImageGen RunPod fix: - Changed from async /run to sync /runsync endpoint - Fixed output parsing for output.images array format with base64 - Other updates: - Added FocusLockIndicator and UserSettingsModal UI components - mulTmux server and shape updates - Automerge sync and store improvements - Various CSS and UI refinements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .gitignore | 1 + multmux/packages/server/src/api/routes.ts | 20 + multmux/packages/server/src/index.ts | 26 +- package-lock.json | 17 + package.json | 4 +- src/automerge/AutomergeToTLStore.ts | 169 ++++- src/automerge/CloudflareAdapter.ts | 8 +- src/automerge/useAutomergeStoreV2.ts | 310 ++++---- src/automerge/useAutomergeSyncRepo.ts | 87 ++- src/components/StarBoardButton.tsx | 16 +- src/components/auth/LoginButton.tsx | 8 +- src/css/crypto-auth.css | 55 +- src/css/starred-boards.css | 67 +- src/css/style.css | 586 ++++++++++++++++ src/hooks/usePinnedToView.ts | 163 +++-- src/routes/Board.tsx | 49 +- src/shapes/ImageGenShapeUtil.tsx | 48 +- src/shapes/MultmuxShapeUtil.tsx | 616 ++++++++++------ src/shapes/MycelialIntelligenceShapeUtil.tsx | 69 ++ src/tools/MultmuxTool.ts | 6 + src/ui/CustomContextMenu.tsx | 4 + src/ui/CustomToolbar.tsx | 602 +++------------- src/ui/FocusLockIndicator.tsx | 135 ++++ src/ui/MycelialIntelligenceBar.tsx | 700 +++++++++++++++++++ src/ui/UserSettingsModal.tsx | 294 ++++++++ src/ui/cameraUtils.ts | 185 +++++ src/ui/components.tsx | 89 +++ src/ui/overrides.tsx | 57 ++ worker/AutomergeDurableObject.ts | 57 +- 29 files changed, 3318 insertions(+), 1130 deletions(-) create mode 100644 src/shapes/MycelialIntelligenceShapeUtil.tsx create mode 100644 src/ui/FocusLockIndicator.tsx create mode 100644 src/ui/MycelialIntelligenceBar.tsx create mode 100644 src/ui/UserSettingsModal.tsx diff --git a/.gitignore b/.gitignore index 16888ad..b150841 100644 --- a/.gitignore +++ b/.gitignore @@ -175,3 +175,4 @@ dist .env.*.local .dev.vars .env.production +.aider* diff --git a/multmux/packages/server/src/api/routes.ts b/multmux/packages/server/src/api/routes.ts index 8148732..796921f 100644 --- a/multmux/packages/server/src/api/routes.ts +++ b/multmux/packages/server/src/api/routes.ts @@ -64,6 +64,26 @@ export function createRouter( }); }); + // Join an existing session (generates a new token and returns session info) + router.post('/sessions/:id/join', (req, res) => { + const session = sessionManager.getSession(req.params.id); + + if (!session) { + return res.status(404).json({ error: 'Session not found' }); + } + + // Generate a new token for this joining client + const token = tokenManager.generateToken(session.id, 60, 'write'); + + res.json({ + id: session.id, + name: session.name, + token, + createdAt: session.createdAt, + activeClients: session.clients.size, + }); + }); + // Generate new invite token for existing session router.post('/sessions/:id/tokens', (req, res) => { const session = sessionManager.getSession(req.params.id); diff --git a/multmux/packages/server/src/index.ts b/multmux/packages/server/src/index.ts index 3b2554a..651598a 100644 --- a/multmux/packages/server/src/index.ts +++ b/multmux/packages/server/src/index.ts @@ -1,4 +1,5 @@ import express from 'express'; +import { createServer } from 'http'; import { WebSocketServer } from 'ws'; import cors from 'cors'; import { SessionManager } from './managers/SessionManager'; @@ -6,8 +7,7 @@ import { TokenManager } from './managers/TokenManager'; import { TerminalHandler } from './websocket/TerminalHandler'; import { createRouter } from './api/routes'; -const PORT = process.env.PORT || 3000; -const WS_PORT = process.env.WS_PORT || 3001; +const PORT = process.env.PORT || 3002; async function main() { // Initialize managers @@ -21,16 +21,15 @@ async function main() { app.use(express.json()); app.use('/api', createRouter(sessionManager, tokenManager)); - app.listen(PORT, () => { - console.log(`mulTmux HTTP API listening on port ${PORT}`); - }); + // Create HTTP server to share with WebSocket + const server = createServer(app); - // WebSocket Server - const wss = new WebSocketServer({ port: Number(WS_PORT) }); + // WebSocket Server on same port, handles upgrade requests + const wss = new WebSocketServer({ server, path: '/ws' }); wss.on('connection', (ws, req) => { // Extract token from query string - const url = new URL(req.url || '', `http://localhost:${WS_PORT}`); + const url = new URL(req.url || '', `http://localhost:${PORT}`); const token = url.searchParams.get('token'); if (!token) { @@ -42,11 +41,12 @@ async function main() { terminalHandler.handleConnection(ws, token); }); - console.log(`mulTmux WebSocket server listening on port ${WS_PORT}`); - console.log(''); - console.log('mulTmux server is ready!'); - console.log(`API: http://localhost:${PORT}/api`); - console.log(`WebSocket: ws://localhost:${WS_PORT}`); + server.listen(PORT, () => { + console.log(''); + console.log('mulTmux server is ready!'); + console.log(`API: http://localhost:${PORT}/api`); + console.log(`WebSocket: ws://localhost:${PORT}/ws`); + }); } main().catch((error) => { diff --git a/package-lock.json b/package-lock.json index 4c7ae7b..2df4dad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,8 @@ "@types/marked": "^5.0.2", "@uiw/react-md-editor": "^4.0.5", "@xenova/transformers": "^2.17.2", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "ai": "^4.1.0", "ajv": "^8.17.1", "cherry-markdown": "^0.8.57", @@ -6007,6 +6009,21 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", diff --git a/package.json b/package.json index 2b076c5..24fc78a 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "multmux/packages/*" ], "scripts": { - "dev": "concurrently --kill-others --names client,worker --prefix-colors blue,red \"npm run dev:client\" \"npm run dev:worker:local\"", + "dev": "concurrently --kill-others --names client,worker,multmux --prefix-colors blue,red,magenta \"npm run dev:client\" \"npm run dev:worker:local\" \"npm run multmux:dev:server\"", "dev:client": "vite --host 0.0.0.0 --port 5173", "dev:worker": "wrangler dev --config wrangler.dev.toml --remote --port 5172", "dev:worker:local": "wrangler dev --config wrangler.dev.toml --port 5172 --ip 0.0.0.0", @@ -43,6 +43,8 @@ "@types/marked": "^5.0.2", "@uiw/react-md-editor": "^4.0.5", "@xenova/transformers": "^2.17.2", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "ai": "^4.1.0", "ajv": "^8.17.1", "cherry-markdown": "^0.8.57", diff --git a/src/automerge/AutomergeToTLStore.ts b/src/automerge/AutomergeToTLStore.ts index 9c3f937..ab04d5b 100644 --- a/src/automerge/AutomergeToTLStore.ts +++ b/src/automerge/AutomergeToTLStore.ts @@ -640,8 +640,9 @@ export function sanitizeRecord(record: any): TLRecord { 'holon': 'Holon', 'obsidianBrowser': 'ObsidianBrowser', 'fathomMeetingsBrowser': 'FathomMeetingsBrowser', - // locationShare removed 'imageGen': 'ImageGen', + 'videoGen': 'VideoGen', + 'multmux': 'Multmux', } // Normalize the shape type if it's a custom type with incorrect case @@ -650,6 +651,68 @@ export function sanitizeRecord(record: any): TLRecord { sanitized.type = customShapeTypeMap[sanitized.type] } + // CRITICAL: Sanitize Multmux shapes AFTER case normalization - ensure all required props exist + // Old shapes may have wsUrl (removed) or undefined values + if (sanitized.type === 'Multmux') { + console.log(`🔧 Sanitizing Multmux shape ${sanitized.id}:`, JSON.stringify(sanitized.props)) + // Remove deprecated wsUrl prop + if ('wsUrl' in sanitized.props) { + delete sanitized.props.wsUrl + } + // CRITICAL: Create a clean props object with all required values + // This ensures no undefined values slip through validation + // Every value MUST be explicitly defined - undefined values cause ValidationError + const w = (typeof sanitized.props.w === 'number' && !isNaN(sanitized.props.w)) ? sanitized.props.w : 800 + const h = (typeof sanitized.props.h === 'number' && !isNaN(sanitized.props.h)) ? sanitized.props.h : 600 + const sessionId = (typeof sanitized.props.sessionId === 'string') ? sanitized.props.sessionId : '' + const sessionName = (typeof sanitized.props.sessionName === 'string') ? sanitized.props.sessionName : '' + const token = (typeof sanitized.props.token === 'string') ? sanitized.props.token : '' + const serverUrl = (typeof sanitized.props.serverUrl === 'string') ? sanitized.props.serverUrl : 'http://localhost:3000' + const pinnedToView = (sanitized.props.pinnedToView === true) ? true : false + // Filter out any undefined or non-string elements from tags array + let tags: string[] = ['terminal', 'multmux'] + if (Array.isArray(sanitized.props.tags)) { + const filteredTags = sanitized.props.tags.filter((t: any) => typeof t === 'string' && t !== '') + if (filteredTags.length > 0) { + tags = filteredTags + } + } + + // Build clean props object - all values are guaranteed to be defined + const cleanProps = { + w: w, + h: h, + sessionId: sessionId, + sessionName: sessionName, + token: token, + serverUrl: serverUrl, + pinnedToView: pinnedToView, + tags: tags, + } + + // CRITICAL: Verify no undefined values before assigning + // This is a safety check - if any value is undefined, something went wrong above + for (const [key, value] of Object.entries(cleanProps)) { + if (value === undefined) { + console.error(`❌ CRITICAL: Multmux prop ${key} is undefined after sanitization! This should never happen.`) + // Fix it with a default value based on key + switch (key) { + case 'w': (cleanProps as any).w = 800; break + case 'h': (cleanProps as any).h = 600; break + case 'sessionId': (cleanProps as any).sessionId = ''; break + case 'sessionName': (cleanProps as any).sessionName = ''; break + case 'token': (cleanProps as any).token = ''; break + case 'serverUrl': (cleanProps as any).serverUrl = 'http://localhost:3000'; break + case 'pinnedToView': (cleanProps as any).pinnedToView = false; break + case 'tags': (cleanProps as any).tags = ['terminal', 'multmux']; break + } + } + } + + sanitized.props = cleanProps + console.log(`🔧 Sanitized Multmux shape ${sanitized.id} props:`, JSON.stringify(sanitized.props)) + } + // CRITICAL: Infer type from properties BEFORE defaulting to 'geo' // This ensures arrows and other shapes are properly recognized if (!sanitized.type || typeof sanitized.type !== 'string') { @@ -784,15 +847,63 @@ export function sanitizeRecord(record: any): TLRecord { // Remove invalid w/h from props (they cause validation errors) if ('w' in sanitized.props) delete sanitized.props.w if ('h' in sanitized.props) delete sanitized.props.h - - // Line shapes REQUIRE points property + + // Line shapes REQUIRE points property with at least 2 points if (!sanitized.props.points || typeof sanitized.props.points !== 'object' || Array.isArray(sanitized.props.points)) { sanitized.props.points = { 'a1': { id: 'a1', index: 'a1' as any, x: 0, y: 0 }, 'a2': { id: 'a2', index: 'a2' as any, x: 100, y: 0 } } + } else { + // Ensure the points object has at least 2 valid points + const pointKeys = Object.keys(sanitized.props.points) + if (pointKeys.length < 2) { + sanitized.props.points = { + 'a1': { id: 'a1', index: 'a1' as any, x: 0, y: 0 }, + 'a2': { id: 'a2', index: 'a2' as any, x: 100, y: 0 } + } + } } } + + // CRITICAL: Fix draw shapes - ensure valid segments structure (required by schema) + // Draw shapes with empty segments cause "No nearest point found" errors + if (sanitized.type === 'draw') { + // Remove invalid w/h from props (they cause validation errors) + if ('w' in sanitized.props) delete sanitized.props.w + if ('h' in sanitized.props) delete sanitized.props.h + + // Draw shapes REQUIRE segments property with at least one segment containing points + if (!sanitized.props.segments || !Array.isArray(sanitized.props.segments) || sanitized.props.segments.length === 0) { + // Create a minimal valid segment with at least 2 points + sanitized.props.segments = [{ + type: 'free', + points: [ + { x: 0, y: 0, z: 0.5 }, + { x: 10, y: 0, z: 0.5 } + ] + }] + } else { + // Ensure each segment has valid points + sanitized.props.segments = sanitized.props.segments.map((segment: any) => { + if (!segment.points || !Array.isArray(segment.points) || segment.points.length < 2) { + return { + type: segment.type || 'free', + points: [ + { x: 0, y: 0, z: 0.5 }, + { x: 10, y: 0, z: 0.5 } + ] + } + } + return segment + }) + } + + // Ensure required draw shape properties exist + if (typeof sanitized.props.isClosed !== 'boolean') sanitized.props.isClosed = false + if (typeof sanitized.props.isComplete !== 'boolean') sanitized.props.isComplete = true + if (typeof sanitized.props.isPen !== 'boolean') sanitized.props.isPen = false + } // CRITICAL: Fix group shapes - remove invalid w/h from props if (sanitized.type === 'group') { @@ -855,8 +966,58 @@ export function sanitizeRecord(record: any): TLRecord { sanitized.props.richText = cleanRichTextNaN(sanitized.props.richText) } - // CRITICAL: Preserve arrow text property (ensure it's a string) + // CRITICAL: Fix arrow shapes - ensure valid start/end structure (required by schema) + // Arrows with invalid start/end cause "No nearest point found" errors if (sanitized.type === 'arrow') { + // Ensure start property exists and has valid structure + if (!sanitized.props.start || typeof sanitized.props.start !== 'object') { + sanitized.props.start = { x: 0, y: 0 } + } else { + // Ensure start has x and y properties (could be bound to a shape or free) + const start = sanitized.props.start as any + if (start.type === 'binding') { + // Binding type must have boundShapeId, normalizedAnchor, and other properties + if (!start.boundShapeId) { + // Invalid binding - convert to point + sanitized.props.start = { x: start.x ?? 0, y: start.y ?? 0 } + } + } else if (start.type === 'point' || start.type === undefined) { + // Point type must have x and y + if (typeof start.x !== 'number' || typeof start.y !== 'number') { + sanitized.props.start = { x: 0, y: 0 } + } + } + } + + // Ensure end property exists and has valid structure + if (!sanitized.props.end || typeof sanitized.props.end !== 'object') { + sanitized.props.end = { x: 100, y: 0 } + } else { + // Ensure end has x and y properties (could be bound to a shape or free) + const end = sanitized.props.end as any + if (end.type === 'binding') { + // Binding type must have boundShapeId + if (!end.boundShapeId) { + // Invalid binding - convert to point + sanitized.props.end = { x: end.x ?? 100, y: end.y ?? 0 } + } + } else if (end.type === 'point' || end.type === undefined) { + // Point type must have x and y + if (typeof end.x !== 'number' || typeof end.y !== 'number') { + sanitized.props.end = { x: 100, y: 0 } + } + } + } + + // Ensure bend is a valid number + if (typeof sanitized.props.bend !== 'number' || isNaN(sanitized.props.bend)) { + sanitized.props.bend = 0 + } + + // Ensure arrowhead properties exist + if (!sanitized.props.arrowheadStart) sanitized.props.arrowheadStart = 'none' + if (!sanitized.props.arrowheadEnd) sanitized.props.arrowheadEnd = 'arrow' + // Ensure text property exists and is a string if (sanitized.props.text === undefined || sanitized.props.text === null) { sanitized.props.text = '' diff --git a/src/automerge/CloudflareAdapter.ts b/src/automerge/CloudflareAdapter.ts index a913e84..db49e1b 100644 --- a/src/automerge/CloudflareAdapter.ts +++ b/src/automerge/CloudflareAdapter.ts @@ -270,8 +270,12 @@ export class CloudflareNetworkAdapter extends NetworkAdapter { } else { // Handle text messages (our custom protocol for backward compatibility) const message = JSON.parse(event.data) - console.log('🔌 CloudflareAdapter: Received WebSocket message:', message.type) - + + // Only log non-presence messages to reduce console spam + if (message.type !== 'presence' && message.type !== 'pong') { + console.log('🔌 CloudflareAdapter: Received WebSocket message:', message.type) + } + // Handle ping/pong messages for keep-alive if (message.type === 'ping') { this.sendPong() diff --git a/src/automerge/useAutomergeStoreV2.ts b/src/automerge/useAutomergeStoreV2.ts index 6f3b6c3..68c412b 100644 --- a/src/automerge/useAutomergeStoreV2.ts +++ b/src/automerge/useAutomergeStoreV2.ts @@ -127,6 +127,8 @@ import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeU import { ImageGenShape } from "@/shapes/ImageGenShapeUtil" import { VideoGenShape } from "@/shapes/VideoGenShapeUtil" import { MultmuxShape } from "@/shapes/MultmuxShapeUtil" +// MycelialIntelligence moved to permanent UI bar - shape kept for backwards compatibility +import { MycelialIntelligenceShape } from "@/shapes/MycelialIntelligenceShapeUtil" // Location shape removed - no longer needed export function useAutomergeStoreV2({ @@ -138,53 +140,87 @@ export function useAutomergeStoreV2({ userId: string adapter?: any }): TLStoreWithStatus { - console.log("useAutomergeStoreV2 called with handle:", !!handle, "adapter:", !!adapter) + // useAutomergeStoreV2 initializing - // Create a custom schema that includes all the custom shapes - const customSchema = createTLSchema({ - shapes: { - ...defaultShapeSchemas, - ChatBox: {} as any, - VideoChat: {} as any, - Embed: {} as any, - Markdown: {} as any, - MycrozineTemplate: {} as any, - Slide: {} as any, - Prompt: {} as any, - Transcription: {} as any, - ObsNote: {} as any, - FathomNote: {} as any, - Holon: {} as any, - ObsidianBrowser: {} as any, - FathomMeetingsBrowser: {} as any, - ImageGen: {} as any, - VideoGen: {} as any, - Multmux: {} as any, - }, - bindings: defaultBindingSchemas, - }) - + // Create store with shape utils and explicit schema for all custom shapes + // Note: Some shapes don't have `static override props`, so we must explicitly list them all const [store] = useState(() => { + const shapeUtils = [ + ChatBoxShape, + VideoChatShape, + EmbedShape, + MarkdownShape, + MycrozineTemplateShape, + SlideShape, + PromptShape, + TranscriptionShape, + ObsNoteShape, + FathomNoteShape, + HolonShape, + ObsidianBrowserShape, + FathomMeetingsBrowserShape, + ImageGenShape, + VideoGenShape, + MultmuxShape, + MycelialIntelligenceShape, // Deprecated - kept for backwards compatibility + ] + + // CRITICAL: Explicitly list ALL custom shape types to ensure they're registered + // This is a fallback in case dynamic extraction from shape utils fails + const knownCustomShapeTypes = [ + 'ChatBox', + 'VideoChat', + 'Embed', + 'Markdown', + 'MycrozineTemplate', + 'Slide', + 'Prompt', + 'Transcription', + 'ObsNote', + 'FathomNote', + 'Holon', + 'ObsidianBrowser', + 'FathomMeetingsBrowser', + 'ImageGen', + 'VideoGen', + 'Multmux', + 'MycelialIntelligence', // Deprecated - kept for backwards compatibility + ] + + // Build schema with explicit entries for all custom shapes + const customShapeSchemas: Record = {} + + // First, register all known custom shape types with empty schemas as fallback + knownCustomShapeTypes.forEach(type => { + customShapeSchemas[type] = {} as any + }) + + // Then, override with actual props for shapes that have them defined + shapeUtils.forEach((util) => { + const type = (util as any).type + if (type && (util as any).props) { + // Shape has static props - use them for proper validation + customShapeSchemas[type] = { + props: (util as any).props, + migrations: (util as any).migrations, + } + } + }) + + // Log what shapes were registered for debugging + // Custom shape schemas registered + + const customSchema = createTLSchema({ + shapes: { + ...defaultShapeSchemas, + ...customShapeSchemas, + }, + bindings: defaultBindingSchemas, + }) + const store = createTLStore({ schema: customSchema, - shapeUtils: [ - ChatBoxShape, - VideoChatShape, - EmbedShape, - MarkdownShape, - MycrozineTemplateShape, - SlideShape, - PromptShape, - TranscriptionShape, - ObsNoteShape, - FathomNoteShape, - HolonShape, - ObsidianBrowserShape, - FathomMeetingsBrowserShape, - ImageGenShape, - VideoGenShape, - MultmuxShape, - ], + shapeUtils: shapeUtils, }) return store }) @@ -199,7 +235,7 @@ export function useAutomergeStoreV2({ const allRecords = storeWithStatus.store.allRecords() const shapes = allRecords.filter(r => r.typeName === 'shape') const pages = allRecords.filter(r => r.typeName === 'page') - console.log(`📊 useAutomergeStoreV2: Store synced with ${allRecords.length} total records, ${shapes.length} shapes, ${pages.length} pages`) + // Store synced } }, [storeWithStatus.status, storeWithStatus.store]) @@ -213,34 +249,47 @@ export function useAutomergeStoreV2({ const unsubs: (() => void)[] = [] - // Track local changes to prevent echoing them back - // Simple boolean flag: set to true when making local changes, - // then reset on the NEXT Automerge change event (which is the echo) - let isLocalChange = false + // Track pending local changes using a COUNTER instead of a boolean. + // The old boolean approach failed because during rapid changes (like dragging), + // multiple echoes could arrive but only the first was skipped. + // With a counter: + // - Increment before each handle.change() + // - Decrement (and skip) for each echo that arrives + // - Process changes only when counter is 0 (those are remote changes) + let pendingLocalChanges = 0 // Helper function to broadcast changes via JSON sync // DISABLED: This causes last-write-wins conflicts // Automerge should handle sync automatically via binary protocol // We're keeping this function but disabling all actual broadcasting - const broadcastJsonSync = (changedRecords: any[]) => { + const broadcastJsonSync = (addedOrUpdatedRecords: any[], deletedRecordIds: string[] = []) => { // TEMPORARY FIX: Manually broadcast changes via WebSocket since Automerge Repo sync isn't working // This sends the full changed records as JSON to other clients // TODO: Fix Automerge Repo's binary sync protocol to work properly - if (!changedRecords || changedRecords.length === 0) { + if ((!addedOrUpdatedRecords || addedOrUpdatedRecords.length === 0) && deletedRecordIds.length === 0) { return } - console.log(`📤 Broadcasting ${changedRecords.length} changed records via manual JSON sync`) + // Broadcasting changes via JSON sync + const shapeRecords = addedOrUpdatedRecords.filter(r => r?.typeName === 'shape') + const deletedShapes = deletedRecordIds.filter(id => id.startsWith('shape:')) + if (shapeRecords.length > 0 || deletedShapes.length > 0) { + console.log(`📤 Broadcasting ${shapeRecords.length} shape changes and ${deletedShapes.length} deletions via JSON sync`) + } if (adapter && typeof (adapter as any).send === 'function') { // Send changes to other clients via the network adapter - (adapter as any).send({ + // CRITICAL: Always include a documentId for the server to process correctly + const docId: string = handle?.documentId || `automerge:${Date.now()}`; + const adapterSend = (adapter as any).send.bind(adapter); + adapterSend({ type: 'sync', data: { - store: Object.fromEntries(changedRecords.map(r => [r.id, r])) + store: Object.fromEntries(addedOrUpdatedRecords.map(r => [r.id, r])), + deleted: deletedRecordIds // Include list of deleted record IDs }, - documentId: handle?.documentId, + documentId: docId, timestamp: Date.now() }) } else { @@ -250,24 +299,32 @@ export function useAutomergeStoreV2({ // Listen for changes from Automerge and apply them to TLDraw const automergeChangeHandler = (payload: DocHandleChangePayload) => { - // Skip the immediate echo of our own local changes - // This flag is set when we update Automerge from TLDraw changes - // and gets reset after skipping one change event (the echo) - if (isLocalChange) { - isLocalChange = false + const patchCount = payload.patches?.length || 0 + const shapePatches = payload.patches?.filter((p: any) => { + const id = p.path?.[1] + return id && typeof id === 'string' && id.startsWith('shape:') + }) || [] + + // Debug logging for sync issues + console.log(`🔄 automergeChangeHandler: ${patchCount} patches (${shapePatches.length} shapes), pendingLocalChanges=${pendingLocalChanges}`) + + // Skip echoes of our own local changes using a counter. + // Each local handle.change() increments the counter, and each echo decrements it. + // Only process changes when counter is 0 (those are remote changes from other clients). + if (pendingLocalChanges > 0) { + console.log(`⏭️ Skipping echo (pendingLocalChanges was ${pendingLocalChanges}, now ${pendingLocalChanges - 1})`) + pendingLocalChanges-- return } + console.log(`✅ Processing ${patchCount} patches as REMOTE changes (${shapePatches.length} shape patches)`) + try { // Apply patches from Automerge to TLDraw store if (payload.patches && payload.patches.length > 0) { // Debug: Check if patches contain shapes - const shapePatches = payload.patches.filter((p: any) => { - const id = p.path?.[1] - return id && typeof id === 'string' && id.startsWith('shape:') - }) if (shapePatches.length > 0) { - console.log(`🔌 Automerge patches contain ${shapePatches.length} shape patches out of ${payload.patches.length} total patches`) + console.log(`📥 Applying ${shapePatches.length} shape patches from remote`) } try { @@ -283,13 +340,10 @@ export function useAutomergeStoreV2({ const shapesAfter = recordsAfter.filter((r: any) => r.typeName === 'shape') if (shapesAfter.length !== shapesBefore.length) { - console.log(`✅ Applied ${payload.patches.length} patches: shapes changed from ${shapesBefore.length} to ${shapesAfter.length}`) + // Patches applied } - // Only log if there are many patches or if debugging is needed - if (payload.patches.length > 5) { - console.log(`✅ Successfully applied ${payload.patches.length} patches`) - } + // Patches processed successfully } catch (patchError) { console.error("Error applying patches batch, attempting individual patch application:", patchError) // Try applying patches one by one to identify problematic ones @@ -349,7 +403,7 @@ export function useAutomergeStoreV2({ } if (successCount < payload.patches.length || payload.patches.length > 5) { - console.log(`✅ Successfully applied ${successCount} out of ${payload.patches.length} patches`) + // Partial patches applied } } } @@ -439,31 +493,30 @@ export function useAutomergeStoreV2({ // Throttle position-only updates (x/y changes) to reduce automerge saves during movement let positionUpdateQueue: RecordsDiff | null = null let positionUpdateTimeout: NodeJS.Timeout | null = null - const POSITION_UPDATE_THROTTLE_MS = 100 // Save position updates every 100ms for real-time feel + const POSITION_UPDATE_THROTTLE_MS = 50 // Save position updates every 50ms for near real-time feel const flushPositionUpdates = () => { if (positionUpdateQueue && handle) { const queuedChanges = positionUpdateQueue positionUpdateQueue = null - - // CRITICAL: Defer position update saves to prevent interrupting active interactions - requestAnimationFrame(() => { - try { - isLocalChange = true - handle.change((doc) => { - applyTLStoreChangesToAutomerge(doc, queuedChanges) - }) - // Trigger sync to broadcast position updates - const changedRecords = [ - ...Object.values(queuedChanges.added || {}), - ...Object.values(queuedChanges.updated || {}), - ...Object.values(queuedChanges.removed || {}) - ] - broadcastJsonSync(changedRecords) - } catch (error) { - console.error("Error applying throttled position updates to Automerge:", error) - } - }) + + // Apply immediately for real-time sync + try { + pendingLocalChanges++ + handle.change((doc) => { + applyTLStoreChangesToAutomerge(doc, queuedChanges) + }) + // Trigger sync to broadcast position updates + // CRITICAL: updated records are [before, after] tuples - extract the 'after' value + const addedOrUpdatedRecords = [ + ...Object.values(queuedChanges.added || {}), + ...Object.values(queuedChanges.updated || {}).map((tuple: any) => Array.isArray(tuple) ? tuple[1] : tuple) + ] + const deletedRecordIds = Object.keys(queuedChanges.removed || {}) + broadcastJsonSync(addedOrUpdatedRecords, deletedRecordIds) + } catch (error) { + console.error("Error applying throttled position updates to Automerge:", error) + } } } @@ -1131,17 +1184,18 @@ export function useAutomergeStoreV2({ // Apply queued changes immediately try { - isLocalChange = true + pendingLocalChanges++ handle.change((doc) => { applyTLStoreChangesToAutomerge(doc, queuedChanges) }) // Trigger sync to broadcast eraser changes - const changedRecords = [ + // CRITICAL: updated records are [before, after] tuples - extract the 'after' value + const addedOrUpdatedRecords = [ ...Object.values(queuedChanges.added || {}), - ...Object.values(queuedChanges.updated || {}), - ...Object.values(queuedChanges.removed || {}) + ...Object.values(queuedChanges.updated || {}).map((tuple: any) => Array.isArray(tuple) ? tuple[1] : tuple) ] - broadcastJsonSync(changedRecords) + const deletedRecordIds = Object.keys(queuedChanges.removed || {}) + broadcastJsonSync(addedOrUpdatedRecords, deletedRecordIds) } catch (error) { console.error('❌ Error applying queued eraser changes:', error) } @@ -1168,49 +1222,37 @@ export function useAutomergeStoreV2({ removed: { ...(queuedChanges.removed || {}), ...(finalFilteredChanges.removed || {}) } } - requestAnimationFrame(() => { - isLocalChange = true - handle.change((doc) => { - applyTLStoreChangesToAutomerge(doc, mergedChanges) - }) - // Trigger sync to broadcast merged changes - const changedRecords = [ - ...Object.values(mergedChanges.added || {}), - ...Object.values(mergedChanges.updated || {}), - ...Object.values(mergedChanges.removed || {}) - ] - broadcastJsonSync(changedRecords) + // Apply immediately for real-time sync + pendingLocalChanges++ + handle.change((doc) => { + applyTLStoreChangesToAutomerge(doc, mergedChanges) }) + // Trigger sync to broadcast merged changes + // CRITICAL: updated records are [before, after] tuples - extract the 'after' value + const addedOrUpdatedRecords = [ + ...Object.values(mergedChanges.added || {}), + ...Object.values(mergedChanges.updated || {}).map((tuple: any) => Array.isArray(tuple) ? tuple[1] : tuple) + ] + const deletedRecordIds = Object.keys(mergedChanges.removed || {}) + broadcastJsonSync(addedOrUpdatedRecords, deletedRecordIds) return } - // OPTIMIZED: Use requestIdleCallback to defer Automerge changes when browser is idle - // This prevents blocking mouse interactions without queuing changes - const applyChanges = () => { - // Mark to prevent feedback loop when this change comes back from Automerge - isLocalChange = true + // Apply changes immediately for real-time sync (no deferral) + // The old requestIdleCallback approach caused multi-second delays + pendingLocalChanges++ + handle.change((doc) => { + applyTLStoreChangesToAutomerge(doc, finalFilteredChanges) + }) - handle.change((doc) => { - applyTLStoreChangesToAutomerge(doc, finalFilteredChanges) - }) - - // CRITICAL: Manually trigger JSON sync broadcast to other clients - // Use requestAnimationFrame to defer this slightly so the change is fully processed - const changedRecords = [ - ...Object.values(finalFilteredChanges.added || {}), - ...Object.values(finalFilteredChanges.updated || {}), - ...Object.values(finalFilteredChanges.removed || {}) - ] - requestAnimationFrame(() => broadcastJsonSync(changedRecords)) - } - - // Use requestIdleCallback if available to apply changes when browser is idle - if (typeof requestIdleCallback !== 'undefined') { - requestIdleCallback(applyChanges, { timeout: 100 }) - } else { - // Fallback: use requestAnimationFrame for next frame - requestAnimationFrame(applyChanges) - } + // CRITICAL: Broadcast immediately for real-time collaboration + // CRITICAL: updated records are [before, after] tuples - extract the 'after' value + const addedOrUpdatedRecords = [ + ...Object.values(finalFilteredChanges.added || {}), + ...Object.values(finalFilteredChanges.updated || {}).map((tuple: any) => Array.isArray(tuple) ? tuple[1] : tuple) + ] + const deletedRecordIds = Object.keys(finalFilteredChanges.removed || {}) + broadcastJsonSync(addedOrUpdatedRecords, deletedRecordIds) } // Only log if there are many changes or if debugging is needed @@ -1255,7 +1297,7 @@ export function useAutomergeStoreV2({ const queuedChanges = eraserChangeQueue eraserChangeQueue = null if (handle) { - isLocalChange = true + pendingLocalChanges++ handle.change((doc) => { applyTLStoreChangesToAutomerge(doc, queuedChanges) }) diff --git a/src/automerge/useAutomergeSyncRepo.ts b/src/automerge/useAutomergeSyncRepo.ts index 06a146d..ca627bb 100644 --- a/src/automerge/useAutomergeSyncRepo.ts +++ b/src/automerge/useAutomergeSyncRepo.ts @@ -94,41 +94,59 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus // JSON sync callback - receives changed records from other clients // Apply to Automerge document which will emit patches to update the store - const applyJsonSyncData = useCallback((data: TLStoreSnapshot) => { + const applyJsonSyncData = useCallback((data: TLStoreSnapshot & { deleted?: string[] }) => { const currentHandle = handleRef.current - if (!currentHandle || !data?.store) { + if (!currentHandle || (!data?.store && !data?.deleted)) { console.warn('⚠️ Cannot apply JSON sync - no handle or data') return } - const changedRecordCount = Object.keys(data.store).length - console.log(`📥 Applying ${changedRecordCount} changed records from JSON sync to Automerge document`) + const changedRecordCount = data.store ? Object.keys(data.store).length : 0 + const shapeRecords = data.store ? Object.values(data.store).filter((r: any) => r?.typeName === 'shape') : [] + const deletedRecordIds = data.deleted || [] + const deletedShapes = deletedRecordIds.filter(id => id.startsWith('shape:')) - // Log shape dimension changes for debugging - Object.entries(data.store).forEach(([id, record]: [string, any]) => { - if (record?.typeName === 'shape' && (record.props?.w || record.props?.h)) { - console.log(`📥 Receiving shape update for ${record.type} ${id}:`, { - w: record.props.w, - h: record.props.h, - x: record.x, - y: record.y + // Log incoming sync data for debugging + console.log(`📥 Received JSON sync: ${changedRecordCount} records (${shapeRecords.length} shapes), ${deletedRecordIds.length} deletions (${deletedShapes.length} shapes)`) + if (shapeRecords.length > 0) { + shapeRecords.forEach((shape: any) => { + console.log(`📥 Shape update: ${shape.type} ${shape.id}`, { + x: shape.x, + y: shape.y, + w: shape.props?.w, + h: shape.props?.h }) - } - }) + }) + } + if (deletedShapes.length > 0) { + console.log(`📥 Shape deletions:`, deletedShapes) + } // Apply changes to the Automerge document // This will trigger patches which will update the TLDraw store + // NOTE: We do NOT increment pendingLocalChanges here because these are REMOTE changes + // that we WANT to be processed by automergeChangeHandler and applied to the store currentHandle.change((doc: any) => { if (!doc.store) { doc.store = {} } // Merge the changed records into the Automerge document - Object.entries(data.store).forEach(([id, record]) => { - doc.store[id] = record - }) + if (data.store) { + Object.entries(data.store).forEach(([id, record]) => { + doc.store[id] = record + }) + } + // Delete records that were removed on the other client + if (deletedRecordIds.length > 0) { + deletedRecordIds.forEach(id => { + if (doc.store[id]) { + delete doc.store[id] + } + }) + } }) - console.log(`✅ Applied ${changedRecordCount} records to Automerge document - patches will update store`) + console.log(`✅ Applied ${changedRecordCount} records and ${deletedRecordIds.length} deletions to Automerge document`) }, []) // Presence update callback - applies presence from other clients @@ -189,7 +207,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus currentStore.put([instancePresence]) }) - console.log(`✅ Applied instance_presence for remote user ${userId}`) + // Presence applied for remote user } catch (error) { console.error('❌ Error applying presence:', error) } @@ -214,7 +232,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus // Log when sync messages are sent/received adapter.on('message', (msg: any) => { - console.log('🔄 CloudflareAdapter received message from network:', msg.type) + // Message received from network }) return { repo, adapter } @@ -226,13 +244,9 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus const initializeHandle = async () => { try { - console.log("🔌 Initializing Automerge Repo with NetworkAdapter for room:", roomId) - // CRITICAL: Wait for the network adapter to be ready before creating document // This ensures the WebSocket connection is established for sync - console.log("⏳ Waiting for network adapter to be ready...") await adapter.whenReady() - console.log("✅ Network adapter is ready, WebSocket connected") if (mounted) { // CRITICAL: Create a new Automerge document (repo.create() generates a proper document ID) @@ -240,18 +254,11 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus // The network adapter broadcasts sync messages between all clients in the same room const handle = repo.create() - console.log("Created Automerge handle via Repo:", { - handleId: handle.documentId, - isReady: handle.isReady(), - roomId: roomId - }) - // Wait for the handle to be ready await handle.whenReady() // CRITICAL: Always load initial data from the server // The server stores documents in R2 as JSON, so we need to load and initialize the Automerge document - console.log("📥 Loading initial data from server...") try { const response = await fetch(`${workerUrl}/room/${roomId}`) if (response.ok) { @@ -259,7 +266,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus const serverShapeCount = serverDoc.store ? Object.values(serverDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0 const serverRecordCount = Object.keys(serverDoc.store || {}).length - console.log(`📥 Loaded document from server: ${serverRecordCount} records, ${serverShapeCount} shapes`) + // Document loaded from server // Initialize the Automerge document with server data if (serverDoc.store && serverRecordCount > 0) { @@ -274,12 +281,12 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus }) }) - console.log(`✅ Initialized Automerge document with ${serverRecordCount} records from server`) + // Initialized Automerge document from server } else { - console.log("📥 Server document is empty - starting with empty Automerge document") + // Server document is empty - starting fresh } } else if (response.status === 404) { - console.log("📥 No document found on server (404) - starting with empty document") + // No document found on server - starting fresh } else { console.warn(`⚠️ Failed to load document from server: ${response.status} ${response.statusText}`) } @@ -293,15 +300,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus const finalStoreKeys = finalDoc?.store ? Object.keys(finalDoc.store).length : 0 const finalShapeCount = finalDoc?.store ? Object.values(finalDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0 - console.log("✅ Automerge handle initialized and ready:", { - handleId: handle.documentId, - isReady: handle.isReady(), - hasDoc: !!finalDoc, - storeKeys: finalStoreKeys, - shapeCount: finalShapeCount, - roomId: roomId - }) - + // Automerge handle initialized and ready setHandle(handle) setIsLoading(false) } diff --git a/src/components/StarBoardButton.tsx b/src/components/StarBoardButton.tsx index f227980..25fa391 100644 --- a/src/components/StarBoardButton.tsx +++ b/src/components/StarBoardButton.tsx @@ -85,15 +85,21 @@ const StarBoardButton: React.FC = ({ className = '' }) => diff --git a/src/components/auth/LoginButton.tsx b/src/components/auth/LoginButton.tsx index 5c855af..1e942d4 100644 --- a/src/components/auth/LoginButton.tsx +++ b/src/components/auth/LoginButton.tsx @@ -33,10 +33,14 @@ const LoginButton: React.FC = ({ className = '' }) => { <> {showLogin && ( diff --git a/src/css/crypto-auth.css b/src/css/crypto-auth.css index ad9b71b..7fa017b 100644 --- a/src/css/crypto-auth.css +++ b/src/css/crypto-auth.css @@ -275,12 +275,8 @@ .toolbar-login-button { margin-right: 0; } - - /* Adjust toolbar container position on mobile */ - .toolbar-container { - right: 35px !important; - gap: 4px !important; - } + + /* Note: toolbar-container positioning is now handled in style.css */ } /* Dark mode support */ @@ -472,52 +468,9 @@ html.dark .user-status { margin-bottom: 0.5rem; } -/* Login Button Styles */ +/* Login Button Styles - extends .toolbar-btn */ .login-button { - background: linear-gradient(135deg, #007bff 0%, #0056b3 100%); - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - font-size: 0.75rem; - font-weight: 600; - transition: all 0.2s ease; - letter-spacing: 0.5px; - white-space: nowrap; - padding: 4px 8px; - height: 22px; - min-height: 22px; - display: flex; - align-items: center; - justify-content: center; - box-sizing: border-box; -} - -.login-button:hover { - background: linear-gradient(135deg, #0056b3 0%, #004085 100%); - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3); -} - -.toolbar-login-button { - margin-right: 0; - height: 22px; - min-height: 22px; - display: flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - flex-shrink: 0; - padding: 4px 8px; - font-size: 0.75rem; - border-radius: 4px; - transition: all 0.2s ease; -} - -.toolbar-login-button:hover { - background: linear-gradient(135deg, #0056b3 0%, #004085 100%); - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3); + /* Base styles come from .toolbar-btn */ } /* Login Modal Overlay */ diff --git a/src/css/starred-boards.css b/src/css/starred-boards.css index aa25671..5b51b3e 100644 --- a/src/css/starred-boards.css +++ b/src/css/starred-boards.css @@ -1,26 +1,6 @@ -/* Star Board Button Styles */ +/* Star Board Button Styles - extends .toolbar-btn */ .star-board-button { - display: flex; - align-items: center; - justify-content: center; - padding: 4px 8px; - background: linear-gradient(135deg, #007bff 0%, #0056b3 100%); - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - font-size: 0.75rem; - font-weight: 600; - transition: all 0.2s ease; - letter-spacing: 0.5px; - white-space: nowrap; - box-sizing: border-box; - line-height: 1.1; - margin: 0; - width: 22px; - height: 22px; - min-width: 22px; - min-height: 22px; + /* Base styles come from .toolbar-btn */ } /* Custom popup notification styles */ @@ -64,48 +44,15 @@ } } -/* Toolbar-specific star button styling to match login button exactly */ -.toolbar-star-button { - padding: 4px 8px; - font-size: 0.75rem; - font-weight: 600; - border-radius: 4px; - background: linear-gradient(135deg, #007bff 0%, #0056b3 100%); - color: white; - border: none; - transition: all 0.2s ease; - letter-spacing: 0.5px; - box-sizing: border-box; - line-height: 1.1; - margin: 0; - width: 22px; - height: 22px; - min-width: 22px; - min-height: 22px; - flex-shrink: 0; -} - -.star-board-button:hover { - background: linear-gradient(135deg, #0056b3 0%, #004085 100%); - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3); -} - -.toolbar-star-button:hover { - background: linear-gradient(135deg, #0056b3 0%, #004085 100%); - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3); -} - +/* Starred state for star button */ .star-board-button.starred { - background: #6B7280; - color: white; + background: var(--tool-bg); + border-color: #eab308; + color: #eab308; } .star-board-button.starred:hover { - background: #4B5563; - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(0,0,0,0.1); + background: rgba(234, 179, 8, 0.1); } .star-board-button:disabled { diff --git a/src/css/style.css b/src/css/style.css index 20113b9..3aeef03 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -905,4 +905,590 @@ input[type="submit"], .prompt-container button:hover, .embed-container button:hover { background-color: var(--hover-bg) !important; +} + +/* ======================================== + Mycelial Intelligence Styles + ======================================== */ + +/* Mycelium network path animation */ +.mycelium-path { + animation: mycelium-flow 4s ease-in-out infinite; + stroke-dasharray: 10 5; +} + +@keyframes mycelium-flow { + 0%, 100% { + stroke-dashoffset: 0; + opacity: 0.1; + } + 50% { + stroke-dashoffset: 30; + opacity: 0.3; + } +} + +/* Loading dots animation */ +.loading-dot { + width: 8px; + height: 8px; + border-radius: 50%; + opacity: 0.3; + animation: loading-pulse 1.4s ease-in-out infinite; +} + +@keyframes loading-pulse { + 0%, 80%, 100% { + opacity: 0.3; + transform: scale(0.8); + } + 40% { + opacity: 1; + transform: scale(1); + } +} + +/* Typing cursor blink */ +@keyframes blink { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0; + } +} + +/* Voice recording pulse animation */ +@keyframes voice-pulse { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(0, 255, 136, 0.4); + } + 50% { + box-shadow: 0 0 0 15px rgba(0, 255, 136, 0); + } +} + +.voice-recording { + animation: voice-pulse 2s ease-in-out infinite; +} + +/* Glow effect on hover for MI buttons */ +.mi-button:hover { + filter: drop-shadow(0 0 10px rgba(0, 255, 136, 0.5)); +} + +/* Mycelial Intelligence input focus state */ +.mi-input:focus { + border-color: #00ff88 !important; + box-shadow: 0 0 15px rgba(0, 255, 136, 0.2) !important; +} + +/* Scrollbar styling for MI chat */ +.mi-chat-container::-webkit-scrollbar { + width: 6px; +} + +.mi-chat-container::-webkit-scrollbar-track { + background: rgba(0, 255, 136, 0.05); + border-radius: 3px; +} + +.mi-chat-container::-webkit-scrollbar-thumb { + background: rgba(0, 255, 136, 0.3); + border-radius: 3px; +} + +.mi-chat-container::-webkit-scrollbar-thumb:hover { + background: rgba(0, 255, 136, 0.5); +} + +/* ======================================== + Toolbar and Share Zone Alignment + ======================================== */ + +/* Position the share zone (people menu) to not overlap with custom toolbar */ +.tlui-share-zone { + position: fixed !important; + top: 4px !important; + right: 8px !important; + z-index: 99998 !important; + display: flex !important; + align-items: center !important; +} + +/* Custom people menu styling */ +.custom-people-menu { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: rgba(255, 255, 255, 0.9); + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +html.dark .custom-people-menu { + background: rgba(45, 55, 72, 0.9); +} + +/* Ensure custom toolbar buttons don't overlap with share zone */ +.toolbar-container { + position: fixed !important; + top: 4px !important; + /* Adjust right position to leave room for people menu (about 80px) */ + right: 90px !important; + z-index: 99999 !important; + display: flex !important; + gap: 6px !important; + align-items: center !important; +} + +/* ======================================== + Unified Toolbar Button Styles + ======================================== */ + +.toolbar-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 4px 10px; + height: 28px; + min-height: 28px; + background: var(--tool-bg); + color: var(--tool-text); + border: 1px solid var(--tool-border); + border-radius: 6px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; + box-sizing: border-box; + flex-shrink: 0; +} + +.toolbar-btn:hover { + background: var(--hover-bg); + border-color: var(--border-color); +} + +.toolbar-btn svg { + flex-shrink: 0; +} + +.profile-btn { + padding: 4px 8px; +} + +.profile-username { + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ======================================== + Profile Dropdown Styles + ======================================== */ + +.profile-dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 240px; + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + z-index: 100000; + overflow: hidden; +} + +.profile-dropdown-header { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + background: var(--code-bg); +} + +.profile-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--tool-border); + display: flex; + align-items: center; + justify-content: center; + color: var(--tool-text); +} + +.profile-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.profile-name { + font-weight: 600; + font-size: 14px; + color: var(--text-color); +} + +.profile-label { + font-size: 11px; + color: var(--tool-text); + opacity: 0.7; +} + +.profile-dropdown-divider { + height: 1px; + background: var(--border-color); + margin: 0; +} + +.profile-dropdown-item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 12px 16px; + background: transparent; + border: none; + color: var(--text-color); + font-size: 13px; + font-weight: 500; + text-decoration: none; + cursor: pointer; + transition: background 0.15s ease; + text-align: left; + box-sizing: border-box; +} + +.profile-dropdown-item:hover { + background: var(--hover-bg); +} + +.profile-dropdown-item svg { + flex-shrink: 0; + opacity: 0.7; +} + +.profile-dropdown-item.danger { + color: #ef4444; +} + +.profile-dropdown-item.danger:hover { + background: rgba(239, 68, 68, 0.1); +} + +.profile-dropdown-warning { + padding: 10px 16px; + font-size: 11px; + color: #d97706; + background: rgba(217, 119, 6, 0.1); + border-left: 3px solid #d97706; +} + +/* ======================================== + Settings Modal Styles + ======================================== */ + +.settings-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 100001; + backdrop-filter: blur(4px); +} + +.settings-modal { + width: 100%; + max-width: 480px; + max-height: 90vh; + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.settings-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border-color); +} + +.settings-modal-header h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text-color); +} + +.settings-close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: transparent; + border: none; + border-radius: 6px; + color: var(--tool-text); + cursor: pointer; + transition: all 0.15s ease; +} + +.settings-close-btn:hover { + background: var(--hover-bg); + color: var(--text-color); +} + +.settings-tabs { + display: flex; + border-bottom: 1px solid var(--border-color); + padding: 0 20px; +} + +.settings-tab { + padding: 12px 16px; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--tool-text); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + margin-bottom: -1px; +} + +.settings-tab:hover { + color: var(--text-color); +} + +.settings-tab.active { + color: #3b82f6; + border-bottom-color: #3b82f6; +} + +.settings-content { + padding: 20px; + overflow-y: auto; + flex: 1; +} + +.settings-section { + display: flex; + flex-direction: column; + gap: 16px; +} + +.settings-item { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.settings-item-info { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; +} + +.settings-item-label { + font-size: 14px; + font-weight: 600; + color: var(--text-color); +} + +.settings-item-description { + font-size: 12px; + color: var(--tool-text); + opacity: 0.8; +} + +.settings-item-status { + flex-shrink: 0; +} + +.status-badge { + display: inline-block; + padding: 4px 8px; + font-size: 11px; + font-weight: 600; + border-radius: 4px; +} + +.status-badge.success { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; +} + +.status-badge.warning { + background: rgba(234, 179, 8, 0.15); + color: #eab308; +} + +.settings-toggle-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--tool-bg); + border: 1px solid var(--tool-border); + border-radius: 6px; + color: var(--tool-text); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.settings-toggle-btn:hover { + background: var(--hover-bg); +} + +.toggle-icon { + font-size: 14px; +} + +.settings-action-btn { + width: 100%; + padding: 10px 16px; + background: #3b82f6; + border: none; + border-radius: 6px; + color: white; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.settings-action-btn:hover { + background: #2563eb; +} + +.settings-action-btn.secondary { + background: var(--tool-bg); + color: var(--tool-text); + border: 1px solid var(--tool-border); +} + +.settings-action-btn.secondary:hover { + background: var(--hover-bg); +} + +.settings-divider { + height: 1px; + background: var(--border-color); + margin: 8px 0; +} + +.settings-input-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.settings-input { + width: 100%; + padding: 10px 12px; + background: var(--bg-color); + border: 1px solid var(--tool-border); + border-radius: 6px; + color: var(--text-color); + font-size: 13px; + box-sizing: border-box; +} + +.settings-input:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.settings-input-actions { + display: flex; + gap: 8px; +} + +.settings-btn-sm { + flex: 1; + padding: 8px 12px; + background: var(--tool-bg); + border: 1px solid var(--tool-border); + border-radius: 6px; + color: var(--tool-text); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.settings-btn-sm:hover { + background: var(--hover-bg); +} + +.settings-btn-sm.primary { + background: #3b82f6; + border-color: #3b82f6; + color: white; +} + +.settings-btn-sm.primary:hover { + background: #2563eb; +} + +.settings-button-group { + display: flex; + gap: 8px; +} + +.settings-button-group .settings-action-btn { + flex: 1; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .toolbar-container { + right: 70px !important; + gap: 4px !important; + } + + .tlui-share-zone { + right: 4px !important; + } + + .custom-people-menu { + padding: 2px 4px; + gap: 2px; + } + + .profile-username { + display: none; + } + + .toolbar-btn { + padding: 4px 8px; + } + + .settings-modal { + max-width: calc(100% - 32px); + margin: 16px; + } } \ No newline at end of file diff --git a/src/hooks/usePinnedToView.ts b/src/hooks/usePinnedToView.ts index fe3cc98..c1341c9 100644 --- a/src/hooks/usePinnedToView.ts +++ b/src/hooks/usePinnedToView.ts @@ -1,11 +1,33 @@ import { useEffect, useRef } from 'react' -import { Editor, TLShapeId } from 'tldraw' +import { Editor, TLShapeId, getIndexAbove } from 'tldraw' + +export interface PinnedViewOptions { + /** + * The position to pin the shape at. + * - 'current': Keep at current screen position (default) + * - 'top-center': Pin to top center of viewport + * - 'bottom-center': Pin to bottom center of viewport + * - 'center': Pin to center of viewport + */ + position?: 'current' | 'top-center' | 'bottom-center' | 'center' + /** + * Offset from the edge (for top-center, bottom-center positions) + */ + offsetY?: number + offsetX?: number +} /** * Hook to manage shapes pinned to the viewport. * When a shape is pinned, it stays in the same screen position as the camera moves. */ -export function usePinnedToView(editor: Editor | null, shapeId: string | undefined, isPinned: boolean) { +export function usePinnedToView( + editor: Editor | null, + shapeId: string | undefined, + isPinned: boolean, + options: PinnedViewOptions = {} +) { + const { position = 'current', offsetY = 0, offsetX = 0 } = options const pinnedScreenPositionRef = useRef<{ x: number; y: number } | null>(null) const originalCoordinatesRef = useRef<{ x: number; y: number } | null>(null) const originalSizeRef = useRef<{ w: number; h: number } | null>(null) @@ -39,67 +61,58 @@ export function usePinnedToView(editor: Editor | null, shapeId: string | undefin } originalZoomRef.current = currentCamera.z - // Get the shape's current page position (top-left corner) - const pagePoint = { x: shape.x, y: shape.y } - // Convert to screen coordinates - this is where we want the shape to stay - const screenPoint = editor.pageToScreen(pagePoint) - + // Calculate screen position based on position option + let screenPoint: { x: number; y: number } + const viewport = editor.getViewportScreenBounds() + const shapeWidth = (shape.props as any).w || 0 + const shapeHeight = (shape.props as any).h || 0 + + if (position === 'top-center') { + // Center horizontally at the top of the viewport + screenPoint = { + x: viewport.x + (viewport.w / 2) - (shapeWidth * currentCamera.z / 2) + offsetX, + y: viewport.y + offsetY, + } + } else if (position === 'bottom-center') { + // Center horizontally at the bottom of the viewport + screenPoint = { + x: viewport.x + (viewport.w / 2) - (shapeWidth * currentCamera.z / 2) + offsetX, + y: viewport.y + viewport.h - (shapeHeight * currentCamera.z) - offsetY, + } + } else if (position === 'center') { + // Center in the viewport + screenPoint = { + x: viewport.x + (viewport.w / 2) - (shapeWidth * currentCamera.z / 2) + offsetX, + y: viewport.y + (viewport.h / 2) - (shapeHeight * currentCamera.z / 2) + offsetY, + } + } else { + // Default: use current position + const pagePoint = { x: shape.x, y: shape.y } + screenPoint = editor.pageToScreen(pagePoint) + } + pinnedScreenPositionRef.current = { x: screenPoint.x, y: screenPoint.y } lastCameraRef.current = { ...currentCamera } - // Bring the shape to the front by setting its index higher than all other shapes + // Bring the shape to the front using tldraw's proper index functions try { const allShapes = editor.getCurrentPageShapes() - let highestIndex = 'a0' - + // Find the highest index among all shapes + let highestIndex = shape.index for (const s of allShapes) { - if (s.index && typeof s.index === 'string') { - // Compare string indices (fractional indexing) - // Higher alphabetical order = higher z-index - if (s.index > highestIndex) { - highestIndex = s.index - } + if (s.id !== shape.id && s.index > highestIndex) { + highestIndex = s.index } } - - // Bring the shape to the front by manually setting index - // Note: sendToFront doesn't exist in this version of tldraw, so we use manual index setting - // Try to set a safe index value - // Use conservative values that are known to work (a1, a2, b1, etc.) - let newIndex: string = 'a2' // Safe default - - // Try to find a valid index higher than existing ones - const allIndices = allShapes - .map(s => s.index) - .filter((idx): idx is any => typeof idx === 'string' && /^[a-z]\d+$/.test(idx)) - .sort() - - if (allIndices.length > 0) { - const highest = allIndices[allIndices.length - 1] - const match = highest.match(/^([a-z])(\d+)$/) - if (match) { - const letter = match[1] - const num = parseInt(match[2], 10) - // Increment number, or move to next letter if number gets too high - if (num < 100) { - newIndex = `${letter}${num + 1}` - } else if (letter < 'y') { - const nextLetter = String.fromCharCode(letter.charCodeAt(0) + 1) - newIndex = `${nextLetter}1` - } else { - // Use a safe value if we're running out of letters - newIndex = 'a2' - } - } - } - - // Validate before using - if (/^[a-z]\d+$/.test(newIndex)) { + + // Only update if we need to move higher + if (highestIndex > shape.index) { + const newIndex = getIndexAbove(highestIndex) editor.updateShape({ id: shapeId as TLShapeId, type: shape.type, - index: newIndex as any, + index: newIndex, }) } } catch (error) { @@ -268,15 +281,44 @@ export function usePinnedToView(editor: Editor | null, shapeId: string | undefin return } - const pinnedScreenPos = pinnedScreenPositionRef.current - if (!pinnedScreenPos) { - animationFrameRef.current = requestAnimationFrame(updatePinnedPosition) - return - } - const currentCamera = editor.getCamera() const lastCamera = lastCameraRef.current + // For preset positions (top-center, etc.), always recalculate based on viewport + // For 'current' position, use the stored screen position + let pinnedScreenPos: { x: number; y: number } + + if (position !== 'current') { + const viewport = editor.getViewportScreenBounds() + const shapeWidth = (currentShape.props as any).w || 0 + const shapeHeight = (currentShape.props as any).h || 0 + + if (position === 'top-center') { + pinnedScreenPos = { + x: viewport.x + (viewport.w / 2) - (shapeWidth * currentCamera.z / 2) + offsetX, + y: viewport.y + offsetY, + } + } else if (position === 'bottom-center') { + pinnedScreenPos = { + x: viewport.x + (viewport.w / 2) - (shapeWidth * currentCamera.z / 2) + offsetX, + y: viewport.y + viewport.h - (shapeHeight * currentCamera.z) - offsetY, + } + } else if (position === 'center') { + pinnedScreenPos = { + x: viewport.x + (viewport.w / 2) - (shapeWidth * currentCamera.z / 2) + offsetX, + y: viewport.y + (viewport.h / 2) - (shapeHeight * currentCamera.z / 2) + offsetY, + } + } else { + pinnedScreenPos = pinnedScreenPositionRef.current! + } + } else { + if (!pinnedScreenPositionRef.current) { + animationFrameRef.current = requestAnimationFrame(updatePinnedPosition) + return + } + pinnedScreenPos = pinnedScreenPositionRef.current + } + // Check if camera has changed significantly const cameraChanged = !lastCamera || ( Math.abs(currentCamera.x - lastCamera.x) > 0.1 || @@ -284,7 +326,10 @@ export function usePinnedToView(editor: Editor | null, shapeId: string | undefin Math.abs(currentCamera.z - lastCamera.z) > 0.001 ) - if (cameraChanged) { + // For preset positions, always check for updates (viewport might have changed) + const shouldUpdate = cameraChanged || position !== 'current' + + if (shouldUpdate) { // Throttle updates to max 60fps (every ~16ms) const timeSinceLastUpdate = timestamp - lastUpdateTimeRef.current const minUpdateInterval = 16 // ~60fps @@ -405,6 +450,6 @@ export function usePinnedToView(editor: Editor | null, shapeId: string | undefin } editor.off('change' as any, handleShapeChange) } - }, [editor, shapeId, isPinned]) + }, [editor, shapeId, isPinned, position, offsetX, offsetY]) } diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index a0022d3..5533706 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -1,7 +1,7 @@ import { useAutomergeSync } from "@/automerge/useAutomergeSync" import { AutomergeHandleProvider } from "@/context/AutomergeHandleContext" import { useMemo, useEffect, useState, useRef } from "react" -import { Tldraw, Editor, TLShapeId, TLRecord, useTldrawUser, TLUserPreferences } from "tldraw" +import { Tldraw, Editor, TLShapeId, TLRecord, useTldrawUser, TLUserPreferences, createShapeId } from "tldraw" import { useParams } from "react-router-dom" import { ChatBoxTool } from "@/tools/ChatBoxTool" import { ChatBoxShape } from "@/shapes/ChatBoxShapeUtil" @@ -46,6 +46,8 @@ import { VideoGenShape } from "@/shapes/VideoGenShapeUtil" import { VideoGenTool } from "@/tools/VideoGenTool" import { MultmuxTool } from "@/tools/MultmuxTool" import { MultmuxShape } from "@/shapes/MultmuxShapeUtil" +// MycelialIntelligence moved to permanent UI bar - shape kept for backwards compatibility +import { MycelialIntelligenceShape } from "@/shapes/MycelialIntelligenceShapeUtil" import { lockElement, unlockElement, @@ -87,6 +89,7 @@ const customShapeUtils = [ ImageGenShape, VideoGenShape, MultmuxShape, + MycelialIntelligenceShape, // Deprecated - kept for backwards compatibility ] const customTools = [ ChatBoxTool, @@ -107,8 +110,7 @@ const customTools = [ ] // Debug: Log tool and shape registration info -console.log('🔧 Board: Custom tools registered:', customTools.map(t => ({ id: t.id, shapeType: t.prototype?.shapeType }))) -console.log('🔧 Board: Custom shapes registered:', customShapeUtils.map(s => ({ type: s.type }))) +// Custom tools and shapes registered export function Board() { const { slug } = useParams<{ slug: string }>() @@ -961,6 +963,47 @@ export function Board() { initializeGlobalCollections(editor, collections) // Note: User presence is configured through the useAutomergeSync hook above // The authenticated username should appear in the people section + + // Auto-create Mycelial Intelligence shape on page load if not present + // Use a flag to ensure this only runs once per session + const miCreatedKey = `mi-created-${roomId}` + const alreadyCreatedThisSession = sessionStorage.getItem(miCreatedKey) + + if (!alreadyCreatedThisSession) { + setTimeout(() => { + const existingMI = editor.getCurrentPageShapes().find(s => s.type === 'MycelialIntelligence') + if (!existingMI) { + const viewport = editor.getViewportScreenBounds() + const miWidth = 520 + const screenCenter = { + x: viewport.x + (viewport.w / 2) - (miWidth / 2), + y: viewport.y + 20, // 20px from top + } + const pagePoint = editor.screenToPage(screenCenter) + + editor.createShape({ + id: createShapeId(), + type: 'MycelialIntelligence', + x: pagePoint.x, + y: pagePoint.y, + props: { + w: miWidth, + h: 52, + prompt: '', + response: '', + isLoading: false, + isListening: false, + isExpanded: false, + conversationHistory: [], + pinnedToView: true, + indexingProgress: 0, + isIndexing: false, + }, + }) + } + sessionStorage.setItem(miCreatedKey, 'true') + }, 500) // Small delay to ensure editor is ready + } }} > diff --git a/src/shapes/ImageGenShapeUtil.tsx b/src/shapes/ImageGenShapeUtil.tsx index 231032d..de97979 100644 --- a/src/shapes/ImageGenShapeUtil.tsx +++ b/src/shapes/ImageGenShapeUtil.tsx @@ -351,7 +351,8 @@ export class ImageGenShape extends BaseBoxShapeUtil { throw new Error("RunPod API key not configured. Please set VITE_RUNPOD_API_KEY environment variable.") } - const url = `https://api.runpod.ai/v2/${endpointId}/run` + // Use runsync for synchronous execution - returns output directly without polling + const url = `https://api.runpod.ai/v2/${endpointId}/runsync` console.log("📤 ImageGen: Sending request to:", url) @@ -375,27 +376,25 @@ export class ImageGenShape extends BaseBoxShapeUtil { } const data = await response.json() as RunPodJobResponse - console.log("📥 ImageGen: Response data:", JSON.stringify(data, null, 2)) + console.log("📥 ImageGen: Response data:", JSON.stringify(data, null, 2).substring(0, 500) + '...') - // Handle async job pattern (RunPod often returns job IDs) - if (data.id && (data.status === 'IN_QUEUE' || data.status === 'IN_PROGRESS' || data.status === 'STARTING')) { - console.log("⏳ ImageGen: Job queued/in progress, polling job ID:", data.id) - const imageUrl = await pollRunPodJob(data.id, apiKey, endpointId) - console.log("✅ ImageGen: Job completed, image URL:", imageUrl) - - this.editor.updateShape({ - id: shape.id, - type: "ImageGen", - props: { - imageUrl: imageUrl, - isLoading: false, - error: null - }, - }) - } else if (data.output) { - // Handle direct response + // With runsync, we get the output directly (no polling needed) + if (data.output) { let imageUrl = '' - if (typeof data.output === 'string') { + + // Handle output.images array format (Automatic1111 endpoint format) + if (Array.isArray(data.output.images) && data.output.images.length > 0) { + const firstImage = data.output.images[0] + // Base64 encoded image string + if (typeof firstImage === 'string') { + imageUrl = firstImage.startsWith('data:') ? firstImage : `data:image/png;base64,${firstImage}` + console.log('✅ ImageGen: Found base64 image in output.images array') + } else if (firstImage.data) { + imageUrl = firstImage.data.startsWith('data:') ? firstImage.data : `data:image/png;base64,${firstImage.data}` + } else if (firstImage.url) { + imageUrl = firstImage.url + } + } else if (typeof data.output === 'string') { imageUrl = data.output } else if (data.output.image) { imageUrl = data.output.image @@ -404,7 +403,7 @@ export class ImageGenShape extends BaseBoxShapeUtil { } else if (Array.isArray(data.output) && data.output.length > 0) { const firstItem = data.output[0] if (typeof firstItem === 'string') { - imageUrl = firstItem + imageUrl = firstItem.startsWith('data:') ? firstItem : `data:image/png;base64,${firstItem}` } else if (firstItem.image) { imageUrl = firstItem.image } else if (firstItem.url) { @@ -413,22 +412,23 @@ export class ImageGenShape extends BaseBoxShapeUtil { } if (imageUrl) { + console.log('✅ ImageGen: Image generated successfully') this.editor.updateShape({ id: shape.id, type: "ImageGen", - props: { + props: { imageUrl: imageUrl, isLoading: false, error: null }, }) } else { - throw new Error("No image URL found in response") + throw new Error("No image URL found in response output") } } else if (data.error) { throw new Error(`RunPod API error: ${data.error}`) } else { - throw new Error("No valid response from RunPod API") + throw new Error("No valid response from RunPod API - missing output field") } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) diff --git a/src/shapes/MultmuxShapeUtil.tsx b/src/shapes/MultmuxShapeUtil.tsx index ef5899e..e5edf20 100644 --- a/src/shapes/MultmuxShapeUtil.tsx +++ b/src/shapes/MultmuxShapeUtil.tsx @@ -1,7 +1,10 @@ import React, { useState, useEffect, useRef } from 'react' -import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer, Geometry2d, Rectangle2d } from 'tldraw' +import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer, Geometry2d, Rectangle2d, T, createShapePropsMigrationIds, createShapePropsMigrationSequence } from 'tldraw' import { StandardizedToolWrapper } from '../components/StandardizedToolWrapper' import { usePinnedToView } from '../hooks/usePinnedToView' +import { Terminal } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import '@xterm/xterm/css/xterm.css' export type IMultmuxShape = TLBaseShape< 'Multmux', @@ -12,7 +15,6 @@ export type IMultmuxShape = TLBaseShape< sessionName: string token: string serverUrl: string - wsUrl: string pinnedToView: boolean tags: string[] } @@ -24,9 +26,81 @@ interface SessionResponse { token: string } +interface SessionListItem { + id: string + name: string + createdAt: string + clientCount: number +} + +// Helper to convert HTTP URL to WebSocket URL +function httpToWs(httpUrl: string): string { + return httpUrl + .replace(/^http:/, 'ws:') + .replace(/^https:/, 'wss:') + .replace(/\/?$/, '/ws') +} + +// Migration versions for Multmux shape +const versions = createShapePropsMigrationIds('Multmux', { + AddMissingProps: 1, + RemoveWsUrl: 2, +}) + +// Migrations to handle shapes with missing/undefined props +export const multmuxShapeMigrations = createShapePropsMigrationSequence({ + sequence: [ + { + id: versions.AddMissingProps, + up: (props: any) => { + return { + w: props.w ?? 800, + h: props.h ?? 600, + sessionId: props.sessionId ?? '', + sessionName: props.sessionName ?? 'New Terminal', + token: props.token ?? '', + serverUrl: props.serverUrl ?? 'http://localhost:3002', + wsUrl: props.wsUrl ?? 'ws://localhost:3002', + pinnedToView: props.pinnedToView ?? false, + tags: Array.isArray(props.tags) ? props.tags : ['terminal', 'multmux'], + } + }, + down: (props: any) => props, + }, + { + id: versions.RemoveWsUrl, + up: (props: any) => { + // Remove wsUrl, it's now derived from serverUrl + const { wsUrl, ...rest } = props + return { + ...rest, + serverUrl: rest.serverUrl ?? 'http://localhost:3002', + } + }, + down: (props: any) => ({ + ...props, + wsUrl: httpToWs(props.serverUrl || 'http://localhost:3002'), + }), + }, + ], +}) + export class MultmuxShape extends BaseBoxShapeUtil { static override type = 'Multmux' as const + static override props = { + w: T.number, + h: T.number, + sessionId: T.string, + sessionName: T.string, + token: T.string, + serverUrl: T.string, + pinnedToView: T.boolean, + tags: T.arrayOf(T.string), + } + + static override migrations = multmuxShapeMigrations + // Terminal theme color: Dark purple/violet static readonly PRIMARY_COLOR = "#8b5cf6" @@ -35,10 +109,9 @@ export class MultmuxShape extends BaseBoxShapeUtil { w: 800, h: 600, sessionId: '', - sessionName: 'New Terminal', + sessionName: '', token: '', - serverUrl: 'http://localhost:3000', - wsUrl: 'ws://localhost:3001', + serverUrl: 'http://localhost:3002', pinnedToView: false, tags: ['terminal', 'multmux'], } @@ -56,11 +129,14 @@ export class MultmuxShape extends BaseBoxShapeUtil { const isSelected = this.editor.getSelectedShapeIds().includes(shape.id) const [isMinimized, setIsMinimized] = useState(false) const [ws, setWs] = useState(null) - const [output, setOutput] = useState([]) - const [input, setInput] = useState('') const [connected, setConnected] = useState(false) + const [showAdvanced, setShowAdvanced] = useState(false) + const [sessions, setSessions] = useState([]) + const [loadingSessions, setLoadingSessions] = useState(false) + const [sessionName, setSessionName] = useState('') const terminalRef = useRef(null) - const inputRef = useRef(null) + const xtermRef = useRef(null) + const fitAddonRef = useRef(null) // Use the pinning hook usePinnedToView(this.editor, shape.id, shape.props.pinnedToView) @@ -87,17 +163,96 @@ export class MultmuxShape extends BaseBoxShapeUtil { }) } - // WebSocket connection + // Fetch available sessions + const fetchSessions = async () => { + setLoadingSessions(true) + try { + const response = await fetch(`${shape.props.serverUrl}/api/sessions`) + if (response.ok) { + const data = await response.json() as { sessions?: SessionListItem[] } + setSessions(data.sessions || []) + } + } catch (error) { + console.error('Failed to fetch sessions:', error) + } finally { + setLoadingSessions(false) + } + } + + // Initialize xterm.js terminal useEffect(() => { - if (!shape.props.token || !shape.props.wsUrl) { + if (!shape.props.token || !terminalRef.current || xtermRef.current) { return } - const websocket = new WebSocket(`${shape.props.wsUrl}?token=${shape.props.token}`) + const term = new Terminal({ + cursorBlink: true, + fontSize: 14, + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + theme: { + background: '#1e1e2e', + foreground: '#cdd6f4', + cursor: '#f5e0dc', + cursorAccent: '#1e1e2e', + black: '#45475a', + red: '#f38ba8', + green: '#a6e3a1', + yellow: '#f9e2af', + blue: '#89b4fa', + magenta: '#cba6f7', + cyan: '#94e2d5', + white: '#bac2de', + brightBlack: '#585b70', + brightRed: '#f38ba8', + brightGreen: '#a6e3a1', + brightYellow: '#f9e2af', + brightBlue: '#89b4fa', + brightMagenta: '#cba6f7', + brightCyan: '#94e2d5', + brightWhite: '#a6adc8', + }, + }) + + const fitAddon = new FitAddon() + term.loadAddon(fitAddon) + term.open(terminalRef.current) + + // Small delay to ensure container is sized + setTimeout(() => { + fitAddon.fit() + }, 100) + + xtermRef.current = term + fitAddonRef.current = fitAddon + + return () => { + term.dispose() + xtermRef.current = null + fitAddonRef.current = null + } + }, [shape.props.token]) + + // Fit terminal when shape resizes + useEffect(() => { + if (fitAddonRef.current && xtermRef.current) { + setTimeout(() => { + fitAddonRef.current?.fit() + }, 50) + } + }, [shape.props.w, shape.props.h, isMinimized]) + + // WebSocket connection + useEffect(() => { + if (!shape.props.token || !shape.props.serverUrl) { + return + } + + const wsUrl = httpToWs(shape.props.serverUrl) + const websocket = new WebSocket(`${wsUrl}?token=${shape.props.token}`) websocket.onopen = () => { setConnected(true) - setOutput(prev => [...prev, '✓ Connected to terminal session']) + xtermRef.current?.writeln('\r\n\x1b[32m✓ Connected to terminal session\x1b[0m\r\n') } websocket.onmessage = (event) => { @@ -106,21 +261,23 @@ export class MultmuxShape extends BaseBoxShapeUtil { switch (message.type) { case 'output': - setOutput(prev => [...prev, message.data]) + // Write terminal output directly to xterm + xtermRef.current?.write(message.data) break case 'joined': - setOutput(prev => [...prev, `✓ Joined session: ${message.sessionName}`]) + xtermRef.current?.writeln(`\r\n\x1b[32m✓ Joined session: ${message.sessionName}\x1b[0m\r\n`) break case 'presence': if (message.data.action === 'join') { - setOutput(prev => [...prev, `→ User joined (${message.data.totalClients} total)`]) + xtermRef.current?.writeln(`\r\n\x1b[33m→ User joined (${message.data.totalClients} total)\x1b[0m`) } else if (message.data.action === 'leave') { - setOutput(prev => [...prev, `← User left (${message.data.totalClients} total)`]) + xtermRef.current?.writeln(`\r\n\x1b[33m← User left (${message.data.totalClients} total)\x1b[0m`) } break case 'error': - setOutput(prev => [...prev, `✗ Error: ${message.message}`]) + xtermRef.current?.writeln(`\r\n\x1b[31m✗ Error: ${message.message}\x1b[0m\r\n`) break + // Ignore 'input' messages from other clients (they're just for awareness) } } catch (error) { console.error('Failed to parse WebSocket message:', error) @@ -129,13 +286,13 @@ export class MultmuxShape extends BaseBoxShapeUtil { websocket.onerror = (error) => { console.error('WebSocket error:', error) - setOutput(prev => [...prev, '✗ Connection error']) + xtermRef.current?.writeln('\r\n\x1b[31m✗ Connection error\x1b[0m\r\n') setConnected(false) } websocket.onclose = () => { setConnected(false) - setOutput(prev => [...prev, '✗ Connection closed']) + xtermRef.current?.writeln('\r\n\x1b[31m✗ Connection closed\x1b[0m\r\n') } setWs(websocket) @@ -143,28 +300,26 @@ export class MultmuxShape extends BaseBoxShapeUtil { return () => { websocket.close() } - }, [shape.props.token, shape.props.wsUrl]) + }, [shape.props.token, shape.props.serverUrl]) - // Auto-scroll terminal output + // Handle terminal input - send keystrokes to server useEffect(() => { - if (terminalRef.current) { - terminalRef.current.scrollTop = terminalRef.current.scrollHeight + if (!xtermRef.current || !ws) return + + const disposable = xtermRef.current.onData((data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + type: 'input', + data: data, + timestamp: Date.now(), + })) + } + }) + + return () => { + disposable.dispose() } - }, [output]) - - const handleInputSubmit = (e: React.FormEvent) => { - e.preventDefault() - if (!input || !ws || !connected) return - - // Send input to terminal - ws.send(JSON.stringify({ - type: 'input', - data: input + '\n', - timestamp: Date.now(), - })) - - setInput('') - } + }, [ws]) const handleCreateSession = async () => { try { @@ -172,7 +327,7 @@ export class MultmuxShape extends BaseBoxShapeUtil { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - name: shape.props.sessionName || 'Canvas Terminal', + name: sessionName || `Terminal ${new Date().toLocaleTimeString()}`, }), }) @@ -182,22 +337,59 @@ export class MultmuxShape extends BaseBoxShapeUtil { const session: SessionResponse = await response.json() - // Update shape with session details + // CRITICAL: Ensure all props are defined - undefined values cause ValidationError + // Explicitly build props object with all required values to prevent undefined from slipping through this.editor.updateShape({ id: shape.id, type: 'Multmux', props: { - ...shape.props, - sessionId: session.id, - sessionName: session.name, - token: session.token, + w: shape.props.w ?? 800, + h: shape.props.h ?? 600, + sessionId: session.id ?? '', + sessionName: session.name ?? '', + token: session.token ?? '', + serverUrl: shape.props.serverUrl ?? 'http://localhost:3002', + pinnedToView: shape.props.pinnedToView ?? false, + tags: Array.isArray(shape.props.tags) ? shape.props.tags : ['terminal', 'multmux'], }, }) - setOutput(prev => [...prev, `✓ Created session: ${session.name}`]) + // Session created - terminal will connect via WebSocket + console.log('✓ Created session:', session.name) } catch (error) { console.error('Failed to create session:', error) - setOutput(prev => [...prev, `✗ Failed to create session: ${error}`]) + } + } + + const handleJoinSession = async (sessionId: string) => { + try { + const response = await fetch(`${shape.props.serverUrl}/api/sessions/${sessionId}/join`, { + method: 'POST', + }) + + if (!response.ok) { + throw new Error('Failed to join session') + } + + const data = await response.json() as { name?: string; token?: string } + + // CRITICAL: Ensure all props are defined - undefined values cause ValidationError + this.editor.updateShape({ + id: shape.id, + type: 'Multmux', + props: { + w: shape.props.w ?? 800, + h: shape.props.h ?? 600, + sessionId: sessionId ?? '', + sessionName: data.name ?? 'Joined Session', + token: data.token ?? '', + serverUrl: shape.props.serverUrl ?? 'http://localhost:3002', + pinnedToView: shape.props.pinnedToView ?? false, + tags: Array.isArray(shape.props.tags) ? shape.props.tags : ['terminal', 'multmux'], + }, + }) + } catch (error) { + console.error('Failed to join session:', error) } } @@ -223,10 +415,7 @@ export class MultmuxShape extends BaseBoxShapeUtil { this.editor.updateShape({ id: shape.id, type: 'Multmux', - props: { - ...shape.props, - tags: newTags, - } + props: { ...shape.props, tags: newTags } }) }} tagsEditable={true} @@ -236,158 +425,176 @@ export class MultmuxShape extends BaseBoxShapeUtil { height: '100%', backgroundColor: '#1e1e2e', color: '#cdd6f4', - padding: '20px', + padding: '24px', fontFamily: 'monospace', pointerEvents: 'all', display: 'flex', flexDirection: 'column', - gap: '16px', + gap: '20px', }}> -

Setup mulTmux Terminal

+
+

mulTmux

+

Collaborative Terminal Sessions

+
+ {/* Create Session */}
- - - - - - + setSessionName(e.target.value)} + placeholder="Session name (optional)" + style={{ + padding: '12px 16px', + backgroundColor: '#313244', + border: '1px solid #45475a', + borderRadius: '8px', + color: '#cdd6f4', + fontFamily: 'monospace', + fontSize: '14px', + }} + onPointerDown={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + /> +
+ + {/* Divider */} +
+
+ OR JOIN EXISTING +
+
+ + {/* Join Session */} +
+ -
-

Or paste a session token:

- { - const token = e.clipboardData.getData('text') - this.editor.updateShape({ - id: shape.id, - type: 'Multmux', - props: { - ...shape.props, - token: token.trim(), - }, - }) - }} - style={{ - width: '100%', - padding: '8px', - marginTop: '4px', - backgroundColor: '#313244', - border: '1px solid #45475a', - borderRadius: '4px', - color: '#cdd6f4', - fontFamily: 'monospace', - }} - onPointerDown={(e) => e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - /> +
+ {sessions.length === 0 ? ( +
+ No active sessions +
+ ) : ( + sessions.map((session) => ( + + )) + )}
+ + {/* Advanced Settings */} +
+ + {showAdvanced && ( +
+ +
+ )} +
@@ -415,10 +622,7 @@ export class MultmuxShape extends BaseBoxShapeUtil { this.editor.updateShape({ id: shape.id, type: 'Multmux', - props: { - ...shape.props, - tags: newTags, - } + props: { ...shape.props, tags: newTags } }) }} tagsEditable={true} @@ -451,45 +655,24 @@ export class MultmuxShape extends BaseBoxShapeUtil {
- {/* Terminal output */} + {/* xterm.js Terminal */}
- {output.map((line, i) => ( -
{line}
- ))} -
- - {/* Input area */} -
- $ - setInput(e.target.value)} - disabled={!connected} - placeholder={connected ? "Type command..." : "Not connected"} - style={{ - flex: 1, - padding: '8px 12px', - backgroundColor: '#313244', - border: 'none', - color: '#cdd6f4', - fontFamily: 'monospace', - outline: 'none', - }} - onPointerDown={(e) => e.stopPropagation()} - /> -
+ onPointerDown={(e) => { + // Allow pointer events for text selection but stop propagation to prevent tldraw interactions + e.stopPropagation() + }} + onClick={(e) => { + e.stopPropagation() + // Focus the terminal when clicked + xtermRef.current?.focus() + }} + />
@@ -501,7 +684,6 @@ export class MultmuxShape extends BaseBoxShapeUtil { } override onDoubleClick = (shape: IMultmuxShape) => { - // Focus input on double click setTimeout(() => { const input = document.querySelector(`[data-shape-id="${shape.id}"] input[type="text"]`) as HTMLInputElement input?.focus() diff --git a/src/shapes/MycelialIntelligenceShapeUtil.tsx b/src/shapes/MycelialIntelligenceShapeUtil.tsx new file mode 100644 index 0000000..d0e7d1f --- /dev/null +++ b/src/shapes/MycelialIntelligenceShapeUtil.tsx @@ -0,0 +1,69 @@ +/** + * DEPRECATED: MycelialIntelligence shape is no longer used as a canvas tool. + * The functionality has been moved to the permanent UI bar (MycelialIntelligenceBar.tsx). + * + * This shape util is kept ONLY for backwards compatibility with existing boards + * that may have MycelialIntelligence shapes saved. It renders a placeholder message. + */ + +import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer } from '@tldraw/tldraw' + +export type IMycelialIntelligenceShape = TLBaseShape< + 'MycelialIntelligence', + { + w: number + h: number + // Keep old props for migration compatibility + prompt?: string + conversationHistory?: Array<{ role: 'user' | 'assistant'; content: string }> + } +> + +export class MycelialIntelligenceShape extends BaseBoxShapeUtil { + static override type = 'MycelialIntelligence' as const + + getDefaultProps(): IMycelialIntelligenceShape['props'] { + return { + w: 400, + h: 300, + } + } + + component(shape: IMycelialIntelligenceShape) { + return ( + +
+ 🍄🧠 + + Mycelial Intelligence + + + This tool has moved to the floating bar at the top of the screen. + + + You can delete this shape - it's no longer needed. + +
+
+ ) + } + + indicator(shape: IMycelialIntelligenceShape) { + return + } +} diff --git a/src/tools/MultmuxTool.ts b/src/tools/MultmuxTool.ts index 36a2fa0..d6c89d1 100644 --- a/src/tools/MultmuxTool.ts +++ b/src/tools/MultmuxTool.ts @@ -109,6 +109,12 @@ export class MultmuxIdle extends StateNode { props: { w: shapeWidth, h: shapeHeight, + sessionId: '', + sessionName: '', + token: '', + serverUrl: 'http://localhost:3000', + pinnedToView: false, + tags: ['terminal', 'multmux'], } }) diff --git a/src/ui/CustomContextMenu.tsx b/src/ui/CustomContextMenu.tsx index a223d60..417eb91 100644 --- a/src/ui/CustomContextMenu.tsx +++ b/src/ui/CustomContextMenu.tsx @@ -173,6 +173,7 @@ export function CustomContextMenu(props: TLUiContextMenuProps) { + @@ -239,6 +240,9 @@ export function CustomContextMenu(props: TLUiContextMenuProps) { + + + {/* Collections Group */} diff --git a/src/ui/CustomToolbar.tsx b/src/ui/CustomToolbar.tsx index ec57774..552f1ed 100644 --- a/src/ui/CustomToolbar.tsx +++ b/src/ui/CustomToolbar.tsx @@ -15,7 +15,8 @@ import { createShapeId } from "tldraw" import type { ObsidianObsNote } from "../lib/obsidianImporter" import { HolonData } from "../lib/HoloSphereService" import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel" -import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey" +import { isFathomApiKeyConfigured } from "../lib/fathomApiKey" +import { UserSettingsModal } from "./UserSettingsModal" // Dark mode utilities const getDarkMode = (): boolean => { @@ -40,13 +41,11 @@ export function CustomToolbar() { const { session, setSession, clearSession } = useAuth() const [showProfilePopup, setShowProfilePopup] = useState(false) + const [showSettingsModal, setShowSettingsModal] = useState(false) const [showVaultBrowser, setShowVaultBrowser] = useState(false) const [showHolonBrowser, setShowHolonBrowser] = useState(false) const [vaultBrowserMode, setVaultBrowserMode] = useState<'keyboard' | 'button'>('keyboard') const [showFathomPanel, setShowFathomPanel] = useState(false) - const [showFathomApiKeyInput, setShowFathomApiKeyInput] = useState(false) - const [fathomApiKeyInput, setFathomApiKeyInput] = useState('') - const [hasFathomApiKey, setHasFathomApiKey] = useState(false) const profilePopupRef = useRef(null) const [isDarkMode, setIsDarkMode] = useState(getDarkMode()) @@ -64,11 +63,7 @@ export function CustomToolbar() { useEffect(() => { if (editor && tools) { setIsReady(true) - // Debug: log available tools - console.log('🔧 CustomToolbar: Available tools:', Object.keys(tools)) - console.log('🔧 CustomToolbar: VideoGen exists:', !!tools["VideoGen"]) - console.log('🔧 CustomToolbar: Multmux exists:', !!tools["Multmux"]) - console.log('🔧 CustomToolbar: ImageGen exists:', !!tools["ImageGen"]) + // Tools are ready } }, [editor, tools]) @@ -95,10 +90,7 @@ export function CustomToolbar() { // Listen for open-fathom-meetings event - now creates a shape instead of modal useEffect(() => { const handleOpenFathomMeetings = () => { - console.log('🔧 Received open-fathom-meetings event') - - // Allow multiple FathomMeetingsBrowser instances - users can work with multiple meeting browsers - console.log('🔧 Creating new FathomMeetingsBrowser shape') + // Allow multiple FathomMeetingsBrowser instances // Get the current viewport center const viewport = editor.getViewportPageBounds() @@ -120,8 +112,6 @@ export function CustomToolbar() { } }) - console.log('✅ Created FathomMeetingsBrowser shape:', browserShape.id) - // Select the new shape and switch to select tool editor.setSelectedShapes([`shape:${browserShape.id}`] as any) editor.setCurrentTool('select') @@ -140,22 +130,18 @@ export function CustomToolbar() { // Listen for open-obsidian-browser event - now creates a shape instead of modal useEffect(() => { const handleOpenBrowser = (event?: CustomEvent) => { - console.log('🔧 Received open-obsidian-browser event') - // Check if ObsidianBrowser already exists const allShapes = editor.getCurrentPageShapes() const existingBrowserShapes = allShapes.filter(shape => shape.type === 'ObsidianBrowser') - + if (existingBrowserShapes.length > 0) { // If a browser already exists, just select it - console.log('✅ ObsidianBrowser already exists, selecting it') editor.setSelectedShapes([existingBrowserShapes[0].id]) editor.setCurrentTool('hand') return } // No existing browser, create a new one - console.log('🔧 Creating new ObsidianBrowser shape') // Try to get click position from event or use current page point let xPosition: number @@ -169,24 +155,21 @@ export function CustomToolbar() { const clickPoint = (event as any)?.detail?.point if (clickPoint) { // Use click coordinates from event - xPosition = clickPoint.x - shapeWidth / 2 // Center the shape on click - yPosition = clickPoint.y - shapeHeight / 2 // Center the shape on click - console.log('📍 Positioning at event click location:', { clickPoint, xPosition, yPosition }) + xPosition = clickPoint.x - shapeWidth / 2 + yPosition = clickPoint.y - shapeHeight / 2 } else { // Try to get current page point (if called from a click) const currentPagePoint = editor.inputs.currentPagePoint if (currentPagePoint && currentPagePoint.x !== undefined && currentPagePoint.y !== undefined) { - xPosition = currentPagePoint.x - shapeWidth / 2 // Center the shape on click - yPosition = currentPagePoint.y - shapeHeight / 2 // Center the shape on click - console.log('📍 Positioning at current page point:', { currentPagePoint, xPosition, yPosition }) + xPosition = currentPagePoint.x - shapeWidth / 2 + yPosition = currentPagePoint.y - shapeHeight / 2 } else { // Fallback to viewport center if no click coordinates available const viewport = editor.getViewportPageBounds() const centerX = viewport.x + viewport.w / 2 const centerY = viewport.y + viewport.h / 2 - xPosition = centerX - shapeWidth / 2 // Center the shape - yPosition = centerY - shapeHeight / 2 // Center the shape - console.log('📍 Positioning at viewport center (fallback):', { centerX, centerY, xPosition, yPosition }) + xPosition = centerX - shapeWidth / 2 + yPosition = centerY - shapeHeight / 2 } } @@ -201,8 +184,6 @@ export function CustomToolbar() { } }) - console.log('✅ Created ObsidianBrowser shape:', browserShape.id) - // Select the new shape and switch to hand tool editor.setSelectedShapes([`shape:${browserShape.id}`] as any) editor.setCurrentTool('hand') @@ -221,22 +202,17 @@ export function CustomToolbar() { // Listen for open-holon-browser event - now creates a shape instead of modal useEffect(() => { const handleOpenHolonBrowser = () => { - console.log('🔧 Received open-holon-browser event') - // Check if a HolonBrowser shape already exists const allShapes = editor.getCurrentPageShapes() const existingBrowserShapes = allShapes.filter(s => s.type === 'HolonBrowser') if (existingBrowserShapes.length > 0) { // If a browser already exists, just select it - console.log('✅ HolonBrowser already exists, selecting it') editor.setSelectedShapes([existingBrowserShapes[0].id]) editor.setCurrentTool('select') return } - console.log('🔧 Creating new HolonBrowser shape') - // Get the current viewport center const viewport = editor.getViewportPageBounds() const centerX = viewport.x + viewport.w / 2 @@ -257,8 +233,6 @@ export function CustomToolbar() { } }) - console.log('✅ Created HolonBrowser shape:', browserShape.id) - // Select the new shape and switch to hand tool editor.setSelectedShapes([`shape:${browserShape.id}`] as any) editor.setCurrentTool('hand') @@ -276,8 +250,6 @@ export function CustomToolbar() { // Handle Holon selection from browser const handleHolonSelect = (holonData: HolonData) => { - console.log('🎯 Creating Holon shape from data:', holonData) - try { // Store current camera position to prevent it from changing const currentCamera = editor.getCamera() @@ -318,8 +290,6 @@ export function CustomToolbar() { } }) - console.log('✅ Created Holon shape from data:', holonShape.id) - // Restore camera position if it changed const newCamera = editor.getCamera() if (currentCamera.x !== newCamera.x || currentCamera.y !== newCamera.y || currentCamera.z !== newCamera.z) { @@ -336,15 +306,12 @@ export function CustomToolbar() { // Listen for create-obsnote-shapes event from the tool useEffect(() => { const handleCreateShapes = () => { - console.log('🎯 CustomToolbar: Received create-obsnote-shapes event') - // If vault browser is open, trigger shape creation if (showVaultBrowser) { const event = new CustomEvent('trigger-obsnote-creation') window.dispatchEvent(event) } else { // If vault browser is not open, open it first - console.log('🎯 Vault browser not open, opening it first') setVaultBrowserMode('keyboard') setShowVaultBrowser(true) } @@ -400,40 +367,14 @@ export function CustomToolbar() { return () => clearInterval(interval) }, []) - // Check Fathom API key status - useEffect(() => { - if (session.authed && session.username) { - const hasKey = isFathomApiKeyConfigured(session.username) - setHasFathomApiKey(hasKey) - } else { - setHasFathomApiKey(false) - } - }, [session.authed, session.username]) - const handleLogout = () => { // Clear the session clearSession() - + // Close the popup setShowProfilePopup(false) } - const openApiKeysDialog = () => { - addDialog({ - id: "api-keys", - component: ({ onClose }: { onClose: () => void }) => ( - { - onClose() - removeDialog("api-keys") - checkApiKeys() // Refresh API key status - }} - /> - ), - }) - } - - const handleObsNoteSelect = (obsNote: ObsidianObsNote) => { // Get current camera position to place the obs_note const camera = editor.getCamera() @@ -566,466 +507,104 @@ export function CustomToolbar() {
- {/* Dark/Light Mode Toggle */} - + + - - - {session.authed && (
- + {showProfilePopup && ( -
-
- CryptID: {session.username} -
- - {/* API Key Status */} -
-
- AI API Keys - - {hasApiKey ? "✅ Configured" : "❌ Not configured"} - +
+
+
+ + +
-

- {hasApiKey - ? "Your AI models are ready to use" - : "Configure API keys to use AI features" - } -

- -
- - {/* Obsidian Vault Settings */} -
-
- Obsidian Vault - - {session.obsidianVaultName ? "✅ Configured" : "❌ Not configured"} - +
+ {session.username} + CryptID Account
- - {session.obsidianVaultName ? ( -
-
- {session.obsidianVaultName} -
-
- {session.obsidianVaultPath === 'folder-selected' - ? 'Folder selected (path not available)' - : session.obsidianVaultPath} -
-
- ) : ( -

- No Obsidian vault configured -

- )} - -
- - {/* Fathom API Key Settings */} -
-
- Fathom API - - {hasFathomApiKey ? "✅ Connected" : "❌ Not connected"} - -
- - {showFathomApiKeyInput ? ( -
- setFathomApiKeyInput(e.target.value)} - placeholder="Enter Fathom API key..." - style={{ - width: "100%", - padding: "6px 8px", - marginBottom: "8px", - border: "1px solid #ddd", - borderRadius: "4px", - fontSize: "12px", - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - if (fathomApiKeyInput.trim()) { - saveFathomApiKey(fathomApiKeyInput.trim(), session.username) - setHasFathomApiKey(true) - setShowFathomApiKeyInput(false) - setFathomApiKeyInput('') - } - } else if (e.key === 'Escape') { - setShowFathomApiKeyInput(false) - setFathomApiKeyInput('') - } - }} - autoFocus - /> -
- - -
-
- ) : ( - <> -

- {hasFathomApiKey - ? "Your Fathom account is connected" - : "Connect your Fathom account to import meetings"} -

-
- - {hasFathomApiKey && ( - - )} -
- - )} -
- - { - e.currentTarget.style.backgroundColor = "#2563EB" - }} - onMouseLeave={(e) => { - e.currentTarget.style.backgroundColor = "#3B82F6" + +
+ + + + + + My Saved Boards + + + + +
+ + + +
+ {!session.backupCreated && ( -
- Remember to back up your encryption keys to prevent data loss! +
+ Back up your encryption keys to prevent data loss
)} - -
)}
)}
+ + {/* Settings Modal */} + {showSettingsModal && ( + setShowSettingsModal(false)} + isDarkMode={isDarkMode} + onToggleDarkMode={toggleDarkMode} + /> + )} {tools["VideoChat"] && ( @@ -1110,6 +689,14 @@ export function CustomToolbar() { isSelected={tools["Holon"].id === editor.getCurrentToolId()} /> )} + {tools["FathomMeetings"] && ( + + )} {tools["ImageGen"] && ( )} + {/* MycelialIntelligence moved to permanent floating bar */} {/* Share Location tool removed for now */} {/* Refresh All ObsNotes Button */} {(() => { diff --git a/src/ui/FocusLockIndicator.tsx b/src/ui/FocusLockIndicator.tsx new file mode 100644 index 0000000..b4b8365 --- /dev/null +++ b/src/ui/FocusLockIndicator.tsx @@ -0,0 +1,135 @@ +import { useEffect, useState } from "react" +import { useEditor } from "tldraw" +import { + onFocusLockChange, + unlockCameraFocus, + getFocusLockedShapeId, +} from "./cameraUtils" +import type { TLShapeId } from "tldraw" + +export function FocusLockIndicator() { + const editor = useEditor() + const [isLocked, setIsLocked] = useState(false) + const [shapeName, setShapeName] = useState("") + + useEffect(() => { + const unsubscribe = onFocusLockChange((locked, shapeId) => { + setIsLocked(locked) + + if (locked && shapeId) { + // Try to get a name for the shape + const shape = editor.getShape(shapeId) + if (shape) { + // Check for common name properties + const name = + (shape.props as any)?.name || + (shape.props as any)?.title || + (shape.meta as any)?.name || + shape.type + setShapeName(name) + } + } else { + setShapeName("") + } + }) + + return () => { + unsubscribe() + } + }, [editor]) + + if (!isLocked) return null + + return ( +
+ + + + + + + Focused on:{" "} + {shapeName || "Shape"} + + + + + + + (Press Esc) + +
+ ) +} diff --git a/src/ui/MycelialIntelligenceBar.tsx b/src/ui/MycelialIntelligenceBar.tsx new file mode 100644 index 0000000..3a0f804 --- /dev/null +++ b/src/ui/MycelialIntelligenceBar.tsx @@ -0,0 +1,700 @@ +import React, { useState, useRef, useEffect, useCallback } from "react" +import { useEditor } from "tldraw" +import { canvasAI, useCanvasAI } from "@/lib/canvasAI" +import { useWebSpeechTranscription } from "@/hooks/useWebSpeechTranscription" + +// Microphone icon component +const MicrophoneIcon = ({ isListening, isDark }: { isListening: boolean; isDark: boolean }) => ( + + + + +) + +// Send icon component +const SendIcon = () => ( + + + +) + +// Expand/collapse icon +const ExpandIcon = ({ isExpanded }: { isExpanded: boolean }) => ( + + + +) + +// Hook to detect dark mode +const useDarkMode = () => { + const [isDark, setIsDark] = useState(() => + document.documentElement.classList.contains('dark') + ) + + useEffect(() => { + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.attributeName === 'class') { + setIsDark(document.documentElement.classList.contains('dark')) + } + }) + }) + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'] + }) + + return () => observer.disconnect() + }, []) + + return isDark +} + +const ACCENT_COLOR = "#10b981" // Emerald green + +interface ConversationMessage { + role: 'user' | 'assistant' + content: string +} + +export function MycelialIntelligenceBar() { + const editor = useEditor() + const isDark = useDarkMode() + const inputRef = useRef(null) + const chatContainerRef = useRef(null) + const containerRef = useRef(null) + + const [prompt, setPrompt] = useState("") + const [isExpanded, setIsExpanded] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [isListening, setIsListening] = useState(false) + const [conversationHistory, setConversationHistory] = useState([]) + const [streamingResponse, setStreamingResponse] = useState("") + const [indexingProgress, setIndexingProgress] = useState(0) + const [isIndexing, setIsIndexing] = useState(false) + const [isHovering, setIsHovering] = useState(false) + + // Initialize canvas AI with editor + useCanvasAI(editor) + + // Theme-aware colors + const colors = { + background: 'rgba(255, 255, 255, 0.98)', + backgroundHover: 'rgba(255, 255, 255, 1)', + border: 'rgba(229, 231, 235, 0.8)', + borderHover: 'rgba(209, 213, 219, 1)', + text: '#18181b', + textMuted: '#71717a', + inputBg: 'rgba(244, 244, 245, 0.8)', + inputBorder: 'rgba(228, 228, 231, 1)', + inputText: '#18181b', + shadow: '0 8px 32px rgba(0, 0, 0, 0.12), 0 4px 16px rgba(0, 0, 0, 0.08)', + shadowHover: '0 12px 40px rgba(0, 0, 0, 0.15), 0 6px 20px rgba(0, 0, 0, 0.1)', + userBubble: 'rgba(16, 185, 129, 0.1)', + assistantBubble: 'rgba(244, 244, 245, 0.8)', + } + + // Voice transcription + const handleTranscriptUpdate = useCallback((text: string) => { + setPrompt(prev => (prev + text).trim()) + }, []) + + const { + isRecording, + isSupported: isVoiceSupported, + startRecording, + stopRecording, + } = useWebSpeechTranscription({ + onTranscriptUpdate: handleTranscriptUpdate, + continuous: false, + interimResults: true, + }) + + // Update isListening state when recording changes + useEffect(() => { + setIsListening(isRecording) + }, [isRecording]) + + // Scroll to bottom when conversation updates + useEffect(() => { + if (chatContainerRef.current) { + chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight + } + }, [conversationHistory, streamingResponse]) + + // Click outside to collapse - detects clicks on canvas or outside the MI bar + useEffect(() => { + if (!isExpanded) return + + const handleClickOutside = (event: MouseEvent | PointerEvent) => { + // Check if click is outside the MI bar container + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsExpanded(false) + } + } + + // Use pointerdown to catch clicks before they reach canvas + document.addEventListener('pointerdown', handleClickOutside, true) + + return () => { + document.removeEventListener('pointerdown', handleClickOutside, true) + } + }, [isExpanded]) + + // Handle voice toggle + const toggleVoice = useCallback(() => { + if (isRecording) { + stopRecording() + } else { + startRecording() + } + }, [isRecording, startRecording, stopRecording]) + + // Handle submit + const handleSubmit = useCallback(async () => { + const trimmedPrompt = prompt.trim() + if (!trimmedPrompt || isLoading) return + + // Clear prompt immediately + setPrompt('') + + const newHistory: ConversationMessage[] = [ + ...conversationHistory, + { role: 'user', content: trimmedPrompt } + ] + + setConversationHistory(newHistory) + setIsLoading(true) + setIsExpanded(true) + setStreamingResponse("") + + try { + const { isIndexing: currentlyIndexing } = canvasAI.getIndexingStatus() + if (!currentlyIndexing) { + setIsIndexing(true) + + await canvasAI.indexCanvas((progress) => { + setIndexingProgress(progress) + }) + + setIsIndexing(false) + setIndexingProgress(100) + } + + let fullResponse = '' + await canvasAI.query( + trimmedPrompt, + (partial, done) => { + fullResponse = partial + setStreamingResponse(partial) + if (done) { + setIsLoading(false) + } + } + ) + + const updatedHistory: ConversationMessage[] = [ + ...newHistory, + { role: 'assistant', content: fullResponse } + ] + + setConversationHistory(updatedHistory) + setStreamingResponse("") + setIsLoading(false) + + } catch (error) { + console.error('Mycelial Intelligence query error:', error) + const errorMessage = error instanceof Error ? error.message : 'An error occurred' + + const errorHistory: ConversationMessage[] = [ + ...newHistory, + { role: 'assistant', content: `Error: ${errorMessage}` } + ] + + setConversationHistory(errorHistory) + setStreamingResponse("") + setIsLoading(false) + } + }, [prompt, isLoading, conversationHistory]) + + // Toggle expanded state + const toggleExpand = useCallback(() => { + setIsExpanded(prev => !prev) + }, []) + + const collapsedHeight = 48 + const expandedHeight = 400 + const barWidth = 520 + const height = isExpanded ? expandedHeight : collapsedHeight + + return ( +
setIsHovering(true)} + onPointerLeave={() => setIsHovering(false)} + > +
+ {/* Collapsed: Single-line prompt bar */} + {!isExpanded && ( +
+ {/* Mushroom + Brain icon */} + + 🍄🧠 + + + {/* Input field */} + setPrompt(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSubmit() + } + }} + onPointerDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + onFocus={(e) => e.stopPropagation()} + placeholder="Ask mi anything about this workspace..." + style={{ + flex: 1, + background: 'transparent', + border: 'none', + padding: '8px 4px', + fontSize: '14px', + color: colors.inputText, + outline: 'none', + }} + /> + + {/* Indexing indicator */} + {isIndexing && ( + + {Math.round(indexingProgress)}% + + )} + + {/* Voice button (compact) */} + {isVoiceSupported && ( + + )} + + {/* Send button (compact, pill shape) */} + + + {/* Expand button if there's history */} + {conversationHistory.length > 0 && ( + + )} +
+ )} + + {/* Expanded: Header + Conversation + Input */} + {isExpanded && ( + <> + {/* Header */} +
+
+ 🍄🧠 + + ask your mycelial intelligence anything about this workspace + + {isIndexing && ( + + Indexing... {Math.round(indexingProgress)}% + + )} +
+ +
+ + {/* Conversation area */} +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + {conversationHistory.length === 0 && !streamingResponse && ( +
+ I can search, summarize, and find connections across all your workspace content. +
+ )} + + {conversationHistory.map((msg, idx) => ( +
+ {msg.content} +
+ ))} + + {/* Streaming response */} + {streamingResponse && ( +
+ {streamingResponse} + {isLoading && ( + + )} +
+ )} + + {/* Loading indicator */} + {isLoading && !streamingResponse && ( +
+ + + +
+ )} +
+ + {/* Input area (expanded) */} +
+ setPrompt(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSubmit() + } + }} + onPointerDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + onFocus={(e) => e.stopPropagation()} + placeholder="Ask a follow-up..." + style={{ + flex: 1, + background: colors.inputBg, + border: `1px solid ${colors.inputBorder}`, + borderRadius: '18px', + padding: '8px 14px', + fontSize: '13px', + color: colors.inputText, + outline: 'none', + transition: 'all 0.2s ease', + }} + /> + + {/* Voice input button */} + {isVoiceSupported && ( + + )} + + {/* Send button */} + +
+ + )} +
+ + {/* CSS animations */} + +
+ ) +} diff --git a/src/ui/UserSettingsModal.tsx b/src/ui/UserSettingsModal.tsx new file mode 100644 index 0000000..fa604a6 --- /dev/null +++ b/src/ui/UserSettingsModal.tsx @@ -0,0 +1,294 @@ +import { useState, useEffect, useRef } from "react" +import { useAuth } from "../context/AuthContext" +import { useDialogs } from "tldraw" +import { SettingsDialog } from "./SettingsDialog" +import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey" + +interface UserSettingsModalProps { + onClose: () => void + isDarkMode: boolean + onToggleDarkMode: () => void +} + +export function UserSettingsModal({ onClose, isDarkMode, onToggleDarkMode }: UserSettingsModalProps) { + const { session, setSession } = useAuth() + const { addDialog, removeDialog } = useDialogs() + const modalRef = useRef(null) + + const [hasApiKey, setHasApiKey] = useState(false) + const [hasFathomApiKey, setHasFathomApiKey] = useState(false) + const [showFathomApiKeyInput, setShowFathomApiKeyInput] = useState(false) + const [fathomApiKeyInput, setFathomApiKeyInput] = useState('') + const [activeTab, setActiveTab] = useState<'general' | 'ai' | 'integrations'>('general') + + // Check API key status + const checkApiKeys = () => { + const settings = localStorage.getItem("openai_api_key") + try { + if (settings) { + try { + const parsed = JSON.parse(settings) + if (parsed.keys) { + const hasValidKey = Object.values(parsed.keys).some(key => + typeof key === 'string' && key.trim() !== '' + ) + setHasApiKey(hasValidKey) + } else { + setHasApiKey(typeof settings === 'string' && settings.trim() !== '') + } + } catch (e) { + setHasApiKey(typeof settings === 'string' && settings.trim() !== '') + } + } else { + setHasApiKey(false) + } + } catch (e) { + setHasApiKey(false) + } + } + + useEffect(() => { + checkApiKeys() + }, []) + + useEffect(() => { + if (session.authed && session.username) { + setHasFathomApiKey(isFathomApiKeyConfigured(session.username)) + } + }, [session.authed, session.username]) + + // Handle escape key and click outside + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose() + } + } + + const handleClickOutside = (e: MouseEvent) => { + if (modalRef.current && !modalRef.current.contains(e.target as Node)) { + onClose() + } + } + + document.addEventListener('keydown', handleEscape) + document.addEventListener('mousedown', handleClickOutside) + + return () => { + document.removeEventListener('keydown', handleEscape) + document.removeEventListener('mousedown', handleClickOutside) + } + }, [onClose]) + + const openApiKeysDialog = () => { + addDialog({ + id: "api-keys", + component: ({ onClose: dialogClose }: { onClose: () => void }) => ( + { + dialogClose() + removeDialog("api-keys") + checkApiKeys() + }} + /> + ), + }) + } + + const handleSetVault = () => { + window.dispatchEvent(new CustomEvent('open-obsidian-browser')) + onClose() + } + + return ( +
+
+
+

Settings

+ +
+ +
+ + + +
+ +
+ {activeTab === 'general' && ( +
+
+
+ Appearance + Toggle between light and dark mode +
+ +
+
+ )} + + {activeTab === 'ai' && ( +
+
+
+ AI API Keys + + {hasApiKey ? 'Your AI models are configured and ready' : 'Configure API keys to use AI features'} + +
+
+ + {hasApiKey ? 'Configured' : 'Not Set'} + +
+
+ +
+ )} + + {activeTab === 'integrations' && ( +
+ {/* Obsidian Vault */} +
+
+ Obsidian Vault + + {session.obsidianVaultName + ? `Connected: ${session.obsidianVaultName}` + : 'Connect your Obsidian vault to import notes'} + +
+
+ + {session.obsidianVaultName ? 'Connected' : 'Not Set'} + +
+
+ + +
+ + {/* Fathom API */} +
+
+ Fathom Meetings + + {hasFathomApiKey + ? 'Your Fathom account is connected' + : 'Connect Fathom to import meeting recordings'} + +
+
+ + {hasFathomApiKey ? 'Connected' : 'Not Set'} + +
+
+ + {showFathomApiKeyInput ? ( +
+ setFathomApiKeyInput(e.target.value)} + placeholder="Enter Fathom API key..." + className="settings-input" + onKeyDown={(e) => { + if (e.key === 'Enter' && fathomApiKeyInput.trim()) { + saveFathomApiKey(fathomApiKeyInput.trim(), session.username) + setHasFathomApiKey(true) + setShowFathomApiKeyInput(false) + setFathomApiKeyInput('') + } else if (e.key === 'Escape') { + setShowFathomApiKeyInput(false) + setFathomApiKeyInput('') + } + }} + autoFocus + /> +
+ + +
+
+ ) : ( +
+ + {hasFathomApiKey && ( + + )} +
+ )} +
+ )} +
+
+
+ ) +} diff --git a/src/ui/cameraUtils.ts b/src/ui/cameraUtils.ts index 6e4bfe2..f9370b8 100644 --- a/src/ui/cameraUtils.ts +++ b/src/ui/cameraUtils.ts @@ -5,6 +5,26 @@ const MAX_HISTORY = 10 // Keep last 10 camera positions const frameObservers = new Map() +// Focus lock state - tracks when camera is locked to a specific shape +let focusLockedShapeId: TLShapeId | null = null +let focusLockCleanup: (() => void) | null = null +let focusLockListeners: Set<(locked: boolean, shapeId: TLShapeId | null) => void> = new Set() + +// Subscribe to focus lock state changes +export const onFocusLockChange = (callback: (locked: boolean, shapeId: TLShapeId | null) => void) => { + focusLockListeners.add(callback) + // Call immediately with current state + callback(focusLockedShapeId !== null, focusLockedShapeId) + return () => focusLockListeners.delete(callback) +} + +const notifyFocusLockListeners = () => { + focusLockListeners.forEach(cb => cb(focusLockedShapeId !== null, focusLockedShapeId)) +} + +export const isCameraFocusLocked = () => focusLockedShapeId !== null +export const getFocusLockedShapeId = () => focusLockedShapeId + // Helper function to store camera position const storeCameraPosition = (editor: Editor) => { const currentCamera = editor.getCamera() @@ -290,6 +310,19 @@ export const setInitialCameraFromUrl = (editor: Editor) => { const zoom = url.searchParams.get("zoom") const shapeId = url.searchParams.get("shapeId") const frameId = url.searchParams.get("frameId") + const focusId = url.searchParams.get("focusId") + + // Handle focus lock mode - locks camera to a specific shape + if (focusId) { + // Small delay to ensure store is loaded + setTimeout(() => { + const success = lockCameraToShape(editor, focusId as TLShapeId) + if (success) { + editor.select(focusId as TLShapeId) + } + }, 100) + return // Don't apply other camera settings when in focus mode + } if (x && y && zoom) { editor.stopCameraAnimation() @@ -342,6 +375,158 @@ export const copyFrameLink = (_editor: Editor, frameId: string) => { navigator.clipboard.writeText(url.toString()) } +// Lock camera to a specific shape - prevents panning/zooming and keeps shape centered +export const lockCameraToShape = (editor: Editor, shapeId: TLShapeId) => { + // Clean up any existing focus lock + if (focusLockCleanup) { + focusLockCleanup() + } + + const shape = editor.getShape(shapeId) + if (!shape) { + console.warn("Cannot lock camera to non-existent shape:", shapeId) + return false + } + + focusLockedShapeId = shapeId + + // Center camera on the shape with appropriate zoom + const bounds = editor.getShapePageBounds(shape) + if (bounds) { + const viewportBounds = editor.getViewportPageBounds() + + // Calculate zoom to fit shape with padding + const padding = 100 + const targetZoom = Math.min( + (viewportBounds.w - padding * 2) / bounds.w, + (viewportBounds.h - padding * 2) / bounds.h, + 2 // Max zoom + ) + + editor.zoomToBounds(bounds, { + targetZoom: Math.max(0.25, Math.min(targetZoom, 2)), + inset: padding, + animation: { duration: 400, easing: (t) => t * (2 - t) }, + }) + } + + // Store original camera interaction methods to restore later + const originalCameraOptions = editor.getCameraOptions() + + // Disable camera panning and zooming + editor.setCameraOptions({ + ...originalCameraOptions, + isLocked: true, + }) + + // Watch for shape position changes to re-center camera + const unsubscribeChange = editor.store.listen((entry) => { + if (!focusLockedShapeId) return + + // Check if the locked shape was updated + for (const record of Object.values(entry.changes.updated)) { + const [_from, to] = record as [TLShape, TLShape] + if (to.id === focusLockedShapeId && to.typeName === 'shape') { + // Shape moved, recenter camera + const newBounds = editor.getShapePageBounds(to) + if (newBounds) { + const currentZoom = editor.getCamera().z + editor.zoomToBounds(newBounds, { + targetZoom: currentZoom, + inset: 100, + animation: { duration: 200 }, + }) + } + } + } + + // Check if locked shape was deleted + for (const id of Object.keys(entry.changes.removed)) { + if (id === focusLockedShapeId) { + unlockCameraFocus(editor) + } + } + }) + + // Cleanup function + focusLockCleanup = () => { + unsubscribeChange() + editor.setCameraOptions({ + ...editor.getCameraOptions(), + isLocked: false, + }) + focusLockedShapeId = null + focusLockCleanup = null + notifyFocusLockListeners() + } + + notifyFocusLockListeners() + return true +} + +// Unlock the camera from focus mode +export const unlockCameraFocus = (_editor: Editor) => { + if (focusLockCleanup) { + focusLockCleanup() + } + + // Update URL to remove focusId + const url = new URL(window.location.href) + url.searchParams.delete("focusId") + window.history.replaceState(null, "", url.toString()) +} + +// Copy a focus link for the selected shape(s) +export const copyFocusLink = async (editor: Editor) => { + const selectedIds = editor.getSelectedShapeIds() + if (selectedIds.length === 0) { + console.warn("No shapes selected for focus link") + return + } + + // Use the first selected shape + const shapeId = selectedIds[0] + const shape = editor.getShape(shapeId) + if (!shape) return + + // Build URL with focusId parameter + const baseUrl = `${window.location.origin}${window.location.pathname}` + const url = new URL(baseUrl) + url.searchParams.set("focusId", shapeId.toString()) + + // Also include current camera bounds for context + const bounds = editor.getShapePageBounds(shape) + if (bounds) { + // Calculate optimal camera position for the shape + const viewportBounds = editor.getViewportPageBounds() + const padding = 100 + const targetZoom = Math.min( + (viewportBounds.w - padding * 2) / bounds.w, + (viewportBounds.h - padding * 2) / bounds.h, + 2 + ) + url.searchParams.set("zoom", Math.max(0.25, Math.min(targetZoom, 2)).toFixed(2)) + } + + const finalUrl = url.toString() + + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(finalUrl) + } else { + const textArea = document.createElement("textarea") + textArea.value = finalUrl + document.body.appendChild(textArea) + textArea.select() + document.execCommand("copy") + document.body.removeChild(textArea) + } + } catch (error) { + console.error("Failed to copy focus link:", error) + alert("Failed to copy link. Please check clipboard permissions.") + } +} + // Initialize lock indicators and watch for changes export const watchForLockedShapes = (editor: Editor) => { editor.on('change', () => { diff --git a/src/ui/components.tsx b/src/ui/components.tsx index c09460c..66d9b08 100644 --- a/src/ui/components.tsx +++ b/src/ui/components.tsx @@ -1,6 +1,8 @@ import { CustomMainMenu } from "./CustomMainMenu" import { CustomToolbar } from "./CustomToolbar" import { CustomContextMenu } from "./CustomContextMenu" +import { FocusLockIndicator } from "./FocusLockIndicator" +import { MycelialIntelligenceBar } from "./MycelialIntelligenceBar" import { DefaultKeyboardShortcutsDialog, DefaultKeyboardShortcutsDialogContent, @@ -8,14 +10,96 @@ import { TldrawUiMenuItem, useTools, useActions, + useEditor, + useValue, } from "tldraw" import { SlidesPanel } from "@/slides/SlidesPanel" +// Custom People Menu component for showing connected users +function CustomPeopleMenu() { + const editor = useEditor() + + // Get current user info + const myUserColor = useValue('myColor', () => editor.user.getColor(), [editor]) + const myUserName = useValue('myName', () => editor.user.getName() || 'You', [editor]) + + // Get all collaborators (other users in the session) + const collaborators = useValue('collaborators', () => editor.getCollaborators(), [editor]) + + return ( +
+ {/* Current user avatar */} +
+ + {/* Other users */} + {collaborators.map((presence) => ( +
+ ))} + + {/* User count badge */} + {collaborators.length > 0 && ( + + {collaborators.length + 1} + + )} +
+ ) +} + +// Custom SharePanel that shows the people menu +function CustomSharePanel() { + return ( +
+ +
+ ) +} + +// Combined InFrontOfCanvas component for floating UI elements +function CustomInFrontOfCanvas() { + return ( + <> + + + + ) +} + export const components: TLComponents = { Toolbar: CustomToolbar, MainMenu: CustomMainMenu, ContextMenu: CustomContextMenu, HelperButtons: SlidesPanel, + SharePanel: CustomSharePanel, + InFrontOfTheCanvas: CustomInFrontOfCanvas, KeyboardShortcutsDialog: (props: any) => { const tools = useTools() const actions = useActions() @@ -34,6 +118,9 @@ export const components: TLComponents = { tools["Holon"], tools["FathomMeetings"], tools["ImageGen"], + tools["VideoGen"], + tools["Multmux"], + // MycelialIntelligence moved to permanent floating bar ].filter(tool => tool && tool.kbd) // Get all custom actions with keyboard shortcuts @@ -42,6 +129,8 @@ export const components: TLComponents = { actions["zoom-out"], actions["zoom-to-selection"], actions["copy-link-to-current-view"], + actions["copy-focus-link"], + actions["unlock-camera-focus"], actions["revert-camera"], actions["lock-element"], actions["save-to-pdf"], diff --git a/src/ui/overrides.tsx b/src/ui/overrides.tsx index 03f9376..db14bad 100644 --- a/src/ui/overrides.tsx +++ b/src/ui/overrides.tsx @@ -8,9 +8,11 @@ import { import { cameraHistory, copyLinkToCurrentView, + copyFocusLink, lockElement, revertCamera, unlockElement, + unlockCameraFocus, zoomToSelection, } from "./cameraUtils" import { saveToPdf } from "../utils/pdfUtils" @@ -228,6 +230,43 @@ export const overrides: TLUiOverrides = { readonlyOk: true, onSelect: () => editor.setCurrentTool("Multmux"), }, + MycelialIntelligence: { + id: "MycelialIntelligence", + icon: "chat", + label: "Mycelial Intelligence", + kbd: "ctrl+shift+m", + readonlyOk: true, + type: "MycelialIntelligence", + onSelect: () => { + // Spawn the MI shape at top center of viewport + const viewport = editor.getViewportPageBounds() + const shapeWidth = 600 + const shapeHeight = 60 + + // Calculate center top position + const x = viewport.x + (viewport.w / 2) - (shapeWidth / 2) + const y = viewport.y + 20 + + // Check if MI already exists on canvas - if so, select it + const existingMI = editor.getCurrentPageShapes().find(s => s.type === 'MycelialIntelligence') + if (existingMI) { + editor.setSelectedShapes([existingMI.id]) + return + } + + // Create the shape + editor.createShape({ + type: 'MycelialIntelligence', + x, + y, + props: { + w: shapeWidth, + h: shapeHeight, + pinnedToView: true, + } + }) + }, + }, hand: { ...tools.hand, onDoubleClick: (info: any) => { @@ -292,6 +331,24 @@ export const overrides: TLUiOverrides = { onSelect: () => copyLinkToCurrentView(editor), readonlyOk: true, }, + copyFocusLink: { + id: "copy-focus-link", + label: "Copy Focus Link (Locked View)", + kbd: "alt+shift+f", + onSelect: () => { + if (editor.getSelectedShapeIds().length > 0) { + copyFocusLink(editor) + } + }, + readonlyOk: true, + }, + unlockCameraFocus: { + id: "unlock-camera-focus", + label: "Unlock Camera", + kbd: "escape", + onSelect: () => unlockCameraFocus(editor), + readonlyOk: true, + }, revertCamera: { id: "revert-camera", label: "Revert Camera", diff --git a/worker/AutomergeDurableObject.ts b/worker/AutomergeDurableObject.ts index b02a45d..757c71c 100644 --- a/worker/AutomergeDurableObject.ts +++ b/worker/AutomergeDurableObject.ts @@ -362,11 +362,13 @@ export class AutomergeDurableObject { break case "sync": // Handle Automerge sync message - if (message.data && message.documentId) { - // This is a sync message with binary data + if (message.data) { + // This is a sync message with data - broadcast to other clients + // CRITICAL: Don't require documentId - JSON sync messages might not have it + // but they still need to be broadcast for real-time collaboration await this.handleSyncMessage(sessionId, message) } else { - // This is a sync request - send current document state + // This is a sync request (no data) - send current document state const doc = await this.getDocument() const client = this.clients.get(sessionId) if (client) { @@ -1417,7 +1419,54 @@ export class AutomergeDurableObject { } }) } - + + // Special handling for Multmux shapes - ensure all required props exist + // Old shapes may have wsUrl (removed) or undefined values + // CRITICAL: Every prop must be explicitly defined - undefined values cause ValidationError + if (record.type === 'Multmux') { + if (!record.props || typeof record.props !== 'object') { + record.props = {} + needsUpdate = true + } + // Remove deprecated wsUrl prop + if ('wsUrl' in record.props) { + delete record.props.wsUrl + needsUpdate = true + } + // CRITICAL: Create clean props with all required values - no undefined allowed + const w = (typeof record.props.w === 'number' && !isNaN(record.props.w)) ? record.props.w : 800 + const h = (typeof record.props.h === 'number' && !isNaN(record.props.h)) ? record.props.h : 600 + const sessionId = (typeof record.props.sessionId === 'string') ? record.props.sessionId : '' + const sessionName = (typeof record.props.sessionName === 'string') ? record.props.sessionName : '' + const token = (typeof record.props.token === 'string') ? record.props.token : '' + const serverUrl = (typeof record.props.serverUrl === 'string') ? record.props.serverUrl : 'http://localhost:3000' + const pinnedToView = (record.props.pinnedToView === true) ? true : false + // Filter out any undefined or non-string elements from tags array + let tags: string[] = ['terminal', 'multmux'] + if (Array.isArray(record.props.tags)) { + const filteredTags = record.props.tags.filter((t: any) => typeof t === 'string' && t !== '') + if (filteredTags.length > 0) { + tags = filteredTags + } + } + // Check if any prop needs updating + if (record.props.w !== w || record.props.h !== h || + record.props.sessionId !== sessionId || record.props.sessionName !== sessionName || + record.props.token !== token || record.props.serverUrl !== serverUrl || + record.props.pinnedToView !== pinnedToView || + JSON.stringify(record.props.tags) !== JSON.stringify(tags)) { + record.props.w = w + record.props.h = h + record.props.sessionId = sessionId + record.props.sessionName = sessionName + record.props.token = token + record.props.serverUrl = serverUrl + record.props.pinnedToView = pinnedToView + record.props.tags = tags + needsUpdate = true + } + } + if (needsUpdate) { migrationStats.migrated++ // Only log detailed migration info for first few shapes to avoid spam