feat: move Mycelial Intelligence to permanent UI bar + fix ImageGen RunPod API

- 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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-11-27 23:57:26 -08:00
parent 30e2219551
commit 144f5365c1
29 changed files with 3318 additions and 1130 deletions

1
.gitignore vendored
View File

@ -175,3 +175,4 @@ dist
.env.*.local
.dev.vars
.env.production
.aider*

View File

@ -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);

View File

@ -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}`);
server.listen(PORT, () => {
console.log('');
console.log('mulTmux server is ready!');
console.log(`API: http://localhost:${PORT}/api`);
console.log(`WebSocket: ws://localhost:${WS_PORT}`);
console.log(`WebSocket: ws://localhost:${PORT}/ws`);
});
}
main().catch((error) => {

17
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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') {
@ -785,14 +848,62 @@ export function sanitizeRecord(record: any): TLRecord {
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 = ''

View File

@ -270,7 +270,11 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
} else {
// Handle text messages (our custom protocol for backward compatibility)
const message = JSON.parse(event.data)
// 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') {

View File

@ -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,36 +140,12 @@ export function useAutomergeStoreV2({
userId: string
adapter?: any
}): TLStoreWithStatus {
console.log("useAutomergeStoreV2 called with handle:", !!handle, "adapter:", !!adapter)
// 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,
})
// useAutomergeStoreV2 initializing
// 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 store = createTLStore({
schema: customSchema,
shapeUtils: [
const shapeUtils = [
ChatBoxShape,
VideoChatShape,
EmbedShape,
@ -184,7 +162,65 @@ export function useAutomergeStoreV2({
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<string, any> = {}
// 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: 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<any>) => {
// 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<TLRecord> | 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(() => {
// Apply immediately for real-time sync
try {
isLocalChange = true
pendingLocalChanges++
handle.change((doc) => {
applyTLStoreChangesToAutomerge(doc, queuedChanges)
})
// Trigger sync to broadcast position updates
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 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
// Apply immediately for real-time sync
pendingLocalChanges++
handle.change((doc) => {
applyTLStoreChangesToAutomerge(doc, mergedChanges)
})
// Trigger sync to broadcast merged changes
const changedRecords = [
// CRITICAL: updated records are [before, after] tuples - extract the 'after' value
const addedOrUpdatedRecords = [
...Object.values(mergedChanges.added || {}),
...Object.values(mergedChanges.updated || {}),
...Object.values(mergedChanges.removed || {})
...Object.values(mergedChanges.updated || {}).map((tuple: any) => Array.isArray(tuple) ? tuple[1] : tuple)
]
broadcastJsonSync(changedRecords)
})
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)
})
// CRITICAL: Manually trigger JSON sync broadcast to other clients
// Use requestAnimationFrame to defer this slightly so the change is fully processed
const changedRecords = [
// 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 || {}),
...Object.values(finalFilteredChanges.removed || {})
...Object.values(finalFilteredChanges.updated || {}).map((tuple: any) => Array.isArray(tuple) ? tuple[1] : tuple)
]
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)
}
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)
})

View File

@ -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
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<TLStoreSnapshot>()
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)
}

View File

@ -85,15 +85,21 @@ const StarBoardButton: React.FC<StarBoardButtonProps> = ({ className = '' }) =>
<button
onClick={handleStarToggle}
disabled={isLoading}
className={`star-board-button ${className} ${isStarred ? 'starred' : ''}`}
className={`toolbar-btn star-board-button ${className} ${isStarred ? 'starred' : ''}`}
title={isStarred ? 'Remove from starred boards' : 'Add to starred boards'}
>
{isLoading ? (
<span className="loading-spinner"></span>
) : isStarred ? (
<span className="star-icon starred"></span>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="loading-spinner">
<path d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
</svg>
) : (
<span className="star-icon"></span>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
{isStarred ? (
<path d="M3.612 15.443c-.386.198-.824-.149-.746-.592l.83-4.73L.173 6.765c-.329-.314-.158-.888.283-.95l4.898-.696L7.538.792c.197-.39.73-.39.927 0l2.184 4.327 4.898.696c.441.062.612.636.282.95l-3.522 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256z"/>
) : (
<path d="M2.866 14.85c-.078.444.36.791.746.593l4.39-2.256 4.389 2.256c.386.198.824-.149.746-.592l-.83-4.73 3.522-3.356c.33-.314.16-.888-.282-.95l-4.898-.696L8.465.792a.513.513 0 0 0-.927 0L5.354 5.12l-4.898.696c-.441.062-.612.636-.283.95l3.523 3.356-.83 4.73zm4.905-2.767-3.686 1.894.694-3.957a.565.565 0 0 0-.163-.505L1.71 6.745l4.052-.576a.525.525 0 0 0 .393-.288L8 2.223l1.847 3.658a.525.525 0 0 0 .393.288l4.052.575-2.906 2.77a.565.565 0 0 0-.163.506l.694 3.957-3.686-1.894a.503.503 0 0 0-.461 0z"/>
)}
</svg>
)}
</button>

View File

@ -33,10 +33,14 @@ const LoginButton: React.FC<LoginButtonProps> = ({ className = '' }) => {
<>
<button
onClick={handleLoginClick}
className={`login-button ${className}`}
className={`toolbar-btn login-button ${className}`}
title="Sign in to save your work and access additional features"
>
Sign In
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path fillRule="evenodd" d="M6 3.5a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v9a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 0-1 0v2A1.5 1.5 0 0 0 6.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 14.5 2h-8A1.5 1.5 0 0 0 5 3.5v2a.5.5 0 0 0 1 0v-2z"/>
<path fillRule="evenodd" d="M11.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 1 0-.708.708L10.293 7.5H1.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z"/>
</svg>
<span>Sign In</span>
</button>
{showLogin && (

View File

@ -276,11 +276,7 @@
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 */

View File

@ -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 {

View File

@ -906,3 +906,589 @@ input[type="submit"],
.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;
}
}

View File

@ -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)
// 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 }
// Convert to screen coordinates - this is where we want the shape to stay
const screenPoint = editor.pageToScreen(pagePoint)
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) {
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,14 +281,43 @@ export function usePinnedToView(editor: Editor | null, shapeId: string | undefin
return
}
const pinnedScreenPos = pinnedScreenPositionRef.current
if (!pinnedScreenPos) {
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
}
const currentCamera = editor.getCamera()
const lastCamera = lastCameraRef.current
pinnedScreenPos = pinnedScreenPositionRef.current
}
// Check if camera has changed significantly
const cameraChanged = !lastCamera || (
@ -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])
}

View File

@ -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
}
}}
>
<CmdK />

View File

@ -351,7 +351,8 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
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<IImageGen> {
}
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<IImageGen>({
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<IImageGen> {
} 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,6 +412,7 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
}
if (imageUrl) {
console.log('✅ ImageGen: Image generated successfully')
this.editor.updateShape<IImageGen>({
id: shape.id,
type: "ImageGen",
@ -423,12 +423,12 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
},
})
} 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)

View File

@ -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<IMultmuxShape> {
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<IMultmuxShape> {
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<IMultmuxShape> {
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
const [isMinimized, setIsMinimized] = useState(false)
const [ws, setWs] = useState<WebSocket | null>(null)
const [output, setOutput] = useState<string[]>([])
const [input, setInput] = useState('')
const [connected, setConnected] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
const [sessions, setSessions] = useState<SessionListItem[]>([])
const [loadingSessions, setLoadingSessions] = useState(false)
const [sessionName, setSessionName] = useState('')
const terminalRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const xtermRef = useRef<Terminal | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
// Use the pinning hook
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
@ -87,17 +163,96 @@ export class MultmuxShape extends BaseBoxShapeUtil<IMultmuxShape> {
})
}
// 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<IMultmuxShape> {
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<IMultmuxShape> {
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<IMultmuxShape> {
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
}
}, [output])
if (!xtermRef.current || !ws) return
const handleInputSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!input || !ws || !connected) return
// Send input to terminal
const disposable = xtermRef.current.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'input',
data: input + '\n',
data: data,
timestamp: Date.now(),
}))
setInput('')
}
})
return () => {
disposable.dispose()
}
}, [ws])
const handleCreateSession = async () => {
try {
@ -172,7 +327,7 @@ export class MultmuxShape extends BaseBoxShapeUtil<IMultmuxShape> {
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<IMultmuxShape> {
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<IMultmuxShape>({
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<IMultmuxShape>({
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<IMultmuxShape> {
this.editor.updateShape<IMultmuxShape>({
id: shape.id,
type: 'Multmux',
props: {
...shape.props,
tags: newTags,
}
props: { ...shape.props, tags: newTags }
})
}}
tagsEditable={true}
@ -236,48 +425,147 @@ export class MultmuxShape extends BaseBoxShapeUtil<IMultmuxShape> {
height: '100%',
backgroundColor: '#1e1e2e',
color: '#cdd6f4',
padding: '20px',
padding: '24px',
fontFamily: 'monospace',
pointerEvents: 'all',
display: 'flex',
flexDirection: 'column',
gap: '16px',
gap: '20px',
}}>
<h3 style={{ margin: 0, color: '#cba6f7' }}>Setup mulTmux Terminal</h3>
<div style={{ textAlign: 'center' }}>
<h2 style={{ margin: '0 0 8px 0', color: '#cba6f7', fontSize: '24px' }}>mulTmux</h2>
<p style={{ margin: 0, opacity: 0.7, fontSize: '14px' }}>Collaborative Terminal Sessions</p>
</div>
{/* Create Session */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<label>
Session Name:
<input
type="text"
value={shape.props.sessionName}
onChange={(e) => {
this.editor.updateShape<IMultmuxShape>({
id: shape.id,
type: 'Multmux',
props: {
...shape.props,
sessionName: e.target.value,
},
})
}}
value={sessionName}
onChange={(e) => setSessionName(e.target.value)}
placeholder="Session name (optional)"
style={{
width: '100%',
padding: '8px',
marginTop: '4px',
padding: '12px 16px',
backgroundColor: '#313244',
border: '1px solid #45475a',
borderRadius: '4px',
borderRadius: '8px',
color: '#cdd6f4',
fontFamily: 'monospace',
fontSize: '14px',
}}
placeholder="Canvas Terminal"
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
/>
</label>
<button
onClick={handleCreateSession}
style={{
padding: '14px 20px',
backgroundColor: '#8b5cf6',
border: 'none',
borderRadius: '8px',
color: 'white',
fontWeight: 'bold',
cursor: 'pointer',
fontFamily: 'monospace',
fontSize: '16px',
transition: 'background-color 0.2s',
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
+ Create New Session
</button>
</div>
<label>
{/* Divider */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ flex: 1, height: '1px', backgroundColor: '#45475a' }} />
<span style={{ opacity: 0.5, fontSize: '12px' }}>OR JOIN EXISTING</span>
<div style={{ flex: 1, height: '1px', backgroundColor: '#45475a' }} />
</div>
{/* Join Session */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '8px', minHeight: 0 }}>
<button
onClick={fetchSessions}
disabled={loadingSessions}
style={{
padding: '10px 16px',
backgroundColor: '#313244',
border: '1px solid #45475a',
borderRadius: '8px',
color: '#cdd6f4',
cursor: 'pointer',
fontFamily: 'monospace',
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{loadingSessions ? 'Loading...' : 'Refresh Sessions'}
</button>
<div style={{
flex: 1,
overflowY: 'auto',
backgroundColor: '#313244',
borderRadius: '8px',
padding: '8px',
}}>
{sessions.length === 0 ? (
<div style={{ textAlign: 'center', padding: '20px', opacity: 0.5 }}>
No active sessions
</div>
) : (
sessions.map((session) => (
<button
key={session.id}
onClick={() => handleJoinSession(session.id)}
style={{
width: '100%',
padding: '12px',
marginBottom: '4px',
backgroundColor: '#45475a',
border: 'none',
borderRadius: '6px',
color: '#cdd6f4',
cursor: 'pointer',
textAlign: 'left',
fontFamily: 'monospace',
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div style={{ fontWeight: 'bold' }}>{session.name}</div>
<div style={{ fontSize: '12px', opacity: 0.7 }}>
{session.clientCount} connected
</div>
</button>
))
)}
</div>
</div>
{/* Advanced Settings */}
<div>
<button
onClick={() => setShowAdvanced(!showAdvanced)}
style={{
background: 'none',
border: 'none',
color: '#89b4fa',
cursor: 'pointer',
fontSize: '12px',
fontFamily: 'monospace',
padding: '4px 0',
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{showAdvanced ? '▼' : '▶'} Advanced Settings
</button>
{showAdvanced && (
<div style={{ marginTop: '8px' }}>
<label style={{ fontSize: '12px', opacity: 0.7 }}>
Server URL:
<input
type="text"
@ -286,10 +574,7 @@ export class MultmuxShape extends BaseBoxShapeUtil<IMultmuxShape> {
this.editor.updateShape<IMultmuxShape>({
id: shape.id,
type: 'Multmux',
props: {
...shape.props,
serverUrl: e.target.value,
},
props: { ...shape.props, serverUrl: e.target.value },
})
}}
style={{
@ -301,92 +586,14 @@ export class MultmuxShape extends BaseBoxShapeUtil<IMultmuxShape> {
borderRadius: '4px',
color: '#cdd6f4',
fontFamily: 'monospace',
fontSize: '12px',
}}
placeholder="http://localhost:3000"
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
/>
</label>
<label>
WebSocket URL:
<input
type="text"
value={shape.props.wsUrl}
onChange={(e) => {
this.editor.updateShape<IMultmuxShape>({
id: shape.id,
type: 'Multmux',
props: {
...shape.props,
wsUrl: e.target.value,
},
})
}}
style={{
width: '100%',
padding: '8px',
marginTop: '4px',
backgroundColor: '#313244',
border: '1px solid #45475a',
borderRadius: '4px',
color: '#cdd6f4',
fontFamily: 'monospace',
}}
placeholder="ws://localhost:3001"
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
/>
</label>
<button
onClick={handleCreateSession}
style={{
padding: '12px',
backgroundColor: '#8b5cf6',
border: 'none',
borderRadius: '4px',
color: 'white',
fontWeight: 'bold',
cursor: 'pointer',
fontFamily: 'monospace',
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
Create New Session
</button>
<div style={{ marginTop: '16px', fontSize: '12px', opacity: 0.8 }}>
<p>Or paste a session token:</p>
<input
type="text"
placeholder="Paste token here..."
onPaste={(e) => {
const token = e.clipboardData.getData('text')
this.editor.updateShape<IMultmuxShape>({
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()}
/>
</div>
)}
</div>
</div>
</StandardizedToolWrapper>
@ -415,10 +622,7 @@ export class MultmuxShape extends BaseBoxShapeUtil<IMultmuxShape> {
this.editor.updateShape<IMultmuxShape>({
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<IMultmuxShape> {
</span>
</div>
{/* Terminal output */}
{/* xterm.js Terminal */}
<div
ref={terminalRef}
style={{
flex: 1,
padding: '12px',
overflowY: 'auto',
overflowX: 'hidden',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
padding: '4px',
overflow: 'hidden',
}}
>
{output.map((line, i) => (
<div key={i}>{line}</div>
))}
</div>
{/* Input area */}
<form onSubmit={handleInputSubmit} style={{ display: 'flex', borderTop: '1px solid #45475a' }}>
<span style={{ padding: '8px 12px', backgroundColor: '#313244', color: '#89b4fa' }}>$</span>
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => 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) => {
// 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()
}}
onPointerDown={(e) => e.stopPropagation()}
/>
</form>
</div>
</StandardizedToolWrapper>
</HTMLContainer>
@ -501,7 +684,6 @@ export class MultmuxShape extends BaseBoxShapeUtil<IMultmuxShape> {
}
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()

View File

@ -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<IMycelialIntelligenceShape> {
static override type = 'MycelialIntelligence' as const
getDefaultProps(): IMycelialIntelligenceShape['props'] {
return {
w: 400,
h: 300,
}
}
component(shape: IMycelialIntelligenceShape) {
return (
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
<div
style={{
width: '100%',
height: '100%',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
border: '2px dashed rgba(16, 185, 129, 0.5)',
borderRadius: '12px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
fontFamily: 'Inter, sans-serif',
color: '#666',
}}
>
<span style={{ fontSize: '32px', marginBottom: '12px' }}>🍄🧠</span>
<span style={{ fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
Mycelial Intelligence
</span>
<span style={{ fontSize: '12px', textAlign: 'center', opacity: 0.8 }}>
This tool has moved to the floating bar at the top of the screen.
</span>
<span style={{ fontSize: '11px', textAlign: 'center', opacity: 0.6, marginTop: '8px' }}>
You can delete this shape - it's no longer needed.
</span>
</div>
</HTMLContainer>
)
}
indicator(shape: IMycelialIntelligenceShape) {
return <rect width={shape.props.w} height={shape.props.h} />
}
}

View File

@ -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'],
}
})

View File

@ -173,6 +173,7 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
<TldrawUiMenuGroup id="camera-controls">
<TldrawUiMenuItem {...customActions.zoomToSelection} disabled={!hasSelection} />
<TldrawUiMenuItem {...customActions.copyLinkToCurrentView} />
<TldrawUiMenuItem {...customActions.copyFocusLink} disabled={!hasSelection} />
<TldrawUiMenuItem {...customActions.revertCamera} disabled={!hasCameraHistory} />
<TldrawUiMenuItem {...customActions.lockElement} disabled={!hasSelection} />
<TldrawUiMenuItem {...customActions.unlockElement} disabled={!hasSelection} />
@ -239,6 +240,9 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
<TldrawUiMenuItem {...tools.FathomMeetings} disabled={hasSelection} />
<TldrawUiMenuItem {...tools.Holon} disabled={hasSelection} />
<TldrawUiMenuItem {...tools.ImageGen} disabled={hasSelection} />
<TldrawUiMenuItem {...tools.VideoGen} disabled={hasSelection} />
<TldrawUiMenuItem {...tools.Multmux} disabled={hasSelection} />
<TldrawUiMenuItem {...tools.MycelialIntelligence} disabled={hasSelection} />
</TldrawUiMenuGroup>
{/* Collections Group */}

View File

@ -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<HTMLDivElement>(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,16 +367,6 @@ 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()
@ -418,22 +375,6 @@ export function CustomToolbar() {
setShowProfilePopup(false)
}
const openApiKeysDialog = () => {
addDialog({
id: "api-keys",
component: ({ onClose }: { onClose: () => void }) => (
<SettingsDialog
onClose={() => {
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() {
<div
className="toolbar-container"
style={{
position: "fixed",
top: "4px",
right: "40px",
zIndex: 99999,
pointerEvents: "auto",
display: "flex",
gap: "6px",
alignItems: "center",
}}
>
{/* Dark/Light Mode Toggle */}
<button
onClick={toggleDarkMode}
style={{
padding: "4px 8px",
borderRadius: "4px",
background: "#6B7280",
color: "white",
border: "none",
cursor: "pointer",
fontWeight: 500,
transition: "background 0.2s ease",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
whiteSpace: "nowrap",
userSelect: "none",
display: "flex",
alignItems: "center",
gap: "6px",
height: "22px",
minHeight: "22px",
boxSizing: "border-box",
fontSize: "0.75rem",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "#4B5563"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "#6B7280"
}}
title={isDarkMode ? "Switch to Light Mode" : "Switch to Dark Mode"}
>
<span style={{ fontSize: "14px" }}>
{isDarkMode ? "☀️" : "🌙"}
</span>
</button>
<LoginButton className="toolbar-login-button" />
<StarBoardButton className="toolbar-star-button" />
<LoginButton className="toolbar-btn" />
<StarBoardButton className="toolbar-btn" />
{session.authed && (
<div style={{ position: "relative" }}>
<button
className="toolbar-btn profile-btn"
onClick={() => setShowProfilePopup(!showProfilePopup)}
style={{
padding: "4px 8px",
borderRadius: "4px",
background: "#6B7280",
color: "white",
border: "none",
cursor: "pointer",
fontWeight: 500,
transition: "background 0.2s ease",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
whiteSpace: "nowrap",
userSelect: "none",
display: "flex",
alignItems: "center",
gap: "6px",
height: "22px",
minHeight: "22px",
boxSizing: "border-box",
fontSize: "0.75rem",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "#4B5563"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "#6B7280"
}}
title={`Signed in as ${session.username}`}
>
<span style={{ fontSize: "12px" }}>
{hasApiKey ? "🔑" : "❌"}
</span>
<span>CryptID: {session.username}</span>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10z"/>
</svg>
<span className="profile-username">{session.username}</span>
</button>
{showProfilePopup && (
<div
ref={profilePopupRef}
style={{
position: "absolute",
top: "40px",
right: "0",
width: "250px",
backgroundColor: "white",
borderRadius: "4px",
boxShadow: "0 2px 10px rgba(0,0,0,0.1)",
padding: "16px",
zIndex: 100000,
}}
>
<div style={{ marginBottom: "12px", fontWeight: "bold" }}>
CryptID: {session.username}
<div ref={profilePopupRef} className="profile-dropdown">
<div className="profile-dropdown-header">
<div className="profile-avatar">
<svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10z"/>
</svg>
</div>
<div className="profile-info">
<span className="profile-name">{session.username}</span>
<span className="profile-label">CryptID Account</span>
</div>
</div>
{/* API Key Status */}
<div style={{
marginBottom: "16px",
padding: "12px",
backgroundColor: hasApiKey ? "#f0f9ff" : "#fef2f2",
borderRadius: "4px",
border: `1px solid ${hasApiKey ? "#0ea5e9" : "#f87171"}`
}}>
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: "8px"
}}>
<span style={{ fontWeight: "500" }}>AI API Keys</span>
<span style={{ fontSize: "14px" }}>
{hasApiKey ? "✅ Configured" : "❌ Not configured"}
</span>
</div>
<p style={{
fontSize: "12px",
color: "#666",
margin: "0 0 8px 0"
}}>
{hasApiKey
? "Your AI models are ready to use"
: "Configure API keys to use AI features"
}
</p>
<button
onClick={openApiKeysDialog}
style={{
width: "100%",
padding: "6px 12px",
backgroundColor: hasApiKey ? "#0ea5e9" : "#ef4444",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "12px",
fontWeight: "500",
transition: "background 0.2s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = hasApiKey ? "#0284c7" : "#dc2626"
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = hasApiKey ? "#0ea5e9" : "#ef4444"
}}
>
{hasApiKey ? "Manage Keys" : "Add API Keys"}
</button>
</div>
<div className="profile-dropdown-divider" />
{/* Obsidian Vault Settings */}
<div style={{
marginBottom: "16px",
padding: "12px",
backgroundColor: "#f8f9fa",
borderRadius: "4px",
border: "1px solid #e9ecef"
}}>
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: "8px"
}}>
<span style={{ fontWeight: "500" }}>Obsidian Vault</span>
<span style={{ fontSize: "14px" }}>
{session.obsidianVaultName ? "✅ Configured" : "❌ Not configured"}
</span>
</div>
{session.obsidianVaultName ? (
<div style={{ marginBottom: "8px" }}>
<div style={{
fontSize: "12px",
color: "#007acc",
fontWeight: "600",
marginBottom: "4px"
}}>
{session.obsidianVaultName}
</div>
<div style={{
fontSize: "11px",
color: "#666",
fontFamily: "monospace",
wordBreak: "break-all"
}}>
{session.obsidianVaultPath === 'folder-selected'
? 'Folder selected (path not available)'
: session.obsidianVaultPath}
</div>
</div>
) : (
<p style={{
fontSize: "12px",
color: "#666",
margin: "0 0 8px 0"
}}>
No Obsidian vault configured
</p>
)}
<button
onClick={() => {
console.log('🔧 Set Vault button clicked, opening folder picker')
setVaultBrowserMode('button')
setShowVaultBrowser(true)
}}
style={{
width: "100%",
padding: "6px 12px",
backgroundColor: session.obsidianVaultName ? "#007acc" : "#28a745",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "12px",
fontWeight: "500",
transition: "background 0.2s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = session.obsidianVaultName ? "#005a9e" : "#218838"
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = session.obsidianVaultName ? "#007acc" : "#28a745"
}}
>
{session.obsidianVaultName ? "Change Vault" : "Set Vault"}
</button>
</div>
{/* Fathom API Key Settings */}
<div style={{
marginBottom: "16px",
padding: "12px",
backgroundColor: hasFathomApiKey ? "#f0f9ff" : "#fef2f2",
borderRadius: "4px",
border: `1px solid ${hasFathomApiKey ? "#0ea5e9" : "#f87171"}`
}}>
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: "8px"
}}>
<span style={{ fontWeight: "500" }}>Fathom API</span>
<span style={{ fontSize: "14px" }}>
{hasFathomApiKey ? "✅ Connected" : "❌ Not connected"}
</span>
</div>
{showFathomApiKeyInput ? (
<div>
<input
type="password"
value={fathomApiKeyInput}
onChange={(e) => 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
/>
<div style={{ display: "flex", gap: "4px" }}>
<button
onClick={() => {
if (fathomApiKeyInput.trim()) {
saveFathomApiKey(fathomApiKeyInput.trim(), session.username)
setHasFathomApiKey(true)
setShowFathomApiKeyInput(false)
setFathomApiKeyInput('')
}
}}
style={{
flex: 1,
padding: "4px 8px",
backgroundColor: "#0ea5e9",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "11px",
}}
>
Save
</button>
<button
onClick={() => {
setShowFathomApiKeyInput(false)
setFathomApiKeyInput('')
}}
style={{
flex: 1,
padding: "4px 8px",
backgroundColor: "#6b7280",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "11px",
}}
>
Cancel
</button>
</div>
</div>
) : (
<>
<p style={{
fontSize: "12px",
color: "#666",
margin: "0 0 8px 0"
}}>
{hasFathomApiKey
? "Your Fathom account is connected"
: "Connect your Fathom account to import meetings"}
</p>
<div style={{ display: "flex", gap: "4px" }}>
<button
onClick={() => {
setShowFathomApiKeyInput(true)
const currentKey = getFathomApiKey(session.username)
if (currentKey) {
setFathomApiKeyInput(currentKey)
}
}}
style={{
flex: 1,
padding: "6px 12px",
backgroundColor: hasFathomApiKey ? "#0ea5e9" : "#ef4444",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "12px",
fontWeight: "500",
}}
>
{hasFathomApiKey ? "Change Key" : "Add API Key"}
</button>
{hasFathomApiKey && (
<button
onClick={() => {
removeFathomApiKey(session.username)
setHasFathomApiKey(false)
}}
style={{
padding: "6px 12px",
backgroundColor: "#6b7280",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "12px",
fontWeight: "500",
}}
>
Disconnect
</button>
)}
</div>
</>
)}
</div>
<a
href="/dashboard/"
target="_blank"
rel="noopener noreferrer"
style={{
display: "block",
width: "100%",
padding: "8px 12px",
backgroundColor: "#3B82F6",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontWeight: "500",
textDecoration: "none",
textAlign: "center",
marginBottom: "8px",
transition: "background 0.2s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#2563EB"
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "#3B82F6"
}}
>
My Dashboard
<a href="/dashboard/" className="profile-dropdown-item">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M11.251.068a.5.5 0 0 1 .227.58L9.677 6.5H13a.5.5 0 0 1 .364.843l-8 8.5a.5.5 0 0 1-.842-.49L6.323 9.5H3a.5.5 0 0 1-.364-.843l8-8.5a.5.5 0 0 1 .615-.09z"/>
</svg>
<span>My Saved Boards</span>
</a>
<button
className="profile-dropdown-item"
onClick={() => {
setShowProfilePopup(false)
setShowSettingsModal(true)
}}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z"/>
<path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.291-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 0 0 1.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.291c.415.764-.42 1.6-1.185 1.184l-.291-.159a1.873 1.873 0 0 0-2.693 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 0 0-2.692-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.291A1.873 1.873 0 0 0 1.945 8.93l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 0 0 3.06 4.377l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 0 0 2.692-1.115l.094-.319z"/>
</svg>
<span>Settings</span>
</button>
<div className="profile-dropdown-divider" />
<button className="profile-dropdown-item" onClick={toggleDarkMode}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
{isDarkMode ? (
<path d="M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8zm.5-9.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0zm0 11a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0zm5-5a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm-11 0a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm9.743-4.036a.5.5 0 1 1-.707-.707.5.5 0 0 1 .707.707zm-7.779 7.779a.5.5 0 1 1-.707-.707.5.5 0 0 1 .707.707zm7.072 0a.5.5 0 1 1 .707-.707.5.5 0 0 1-.707.707zM3.757 4.464a.5.5 0 1 1 .707-.707.5.5 0 0 1-.707.707z"/>
) : (
<path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z"/>
)}
</svg>
<span>{isDarkMode ? 'Light Mode' : 'Dark Mode'}</span>
</button>
<div className="profile-dropdown-divider" />
{!session.backupCreated && (
<div style={{
marginBottom: "12px",
fontSize: "12px",
color: "#666",
padding: "8px",
backgroundColor: "#f8f8f8",
borderRadius: "4px"
}}>
Remember to back up your encryption keys to prevent data loss!
<div className="profile-dropdown-warning">
Back up your encryption keys to prevent data loss
</div>
)}
<button
onClick={handleLogout}
style={{
width: "100%",
padding: "8px 12px",
backgroundColor: "#EF4444",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontWeight: "500",
transition: "background 0.2s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#DC2626"
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "#EF4444"
}}
>
Sign Out
<button className="profile-dropdown-item danger" onClick={handleLogout}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path fillRule="evenodd" d="M10 12.5a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v2a.5.5 0 0 0 1 0v-2A1.5 1.5 0 0 0 9.5 2h-8A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-2a.5.5 0 0 0-1 0v2z"/>
<path fillRule="evenodd" d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 0 0-.708.708L14.293 7.5H5.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z"/>
</svg>
<span>Sign Out</span>
</button>
</div>
)}
</div>
)}
</div>
{/* Settings Modal */}
{showSettingsModal && (
<UserSettingsModal
onClose={() => setShowSettingsModal(false)}
isDarkMode={isDarkMode}
onToggleDarkMode={toggleDarkMode}
/>
)}
<DefaultToolbar>
<DefaultToolbarContent />
{tools["VideoChat"] && (
@ -1110,6 +689,14 @@ export function CustomToolbar() {
isSelected={tools["Holon"].id === editor.getCurrentToolId()}
/>
)}
{tools["FathomMeetings"] && (
<TldrawUiMenuItem
{...tools["FathomMeetings"]}
icon="calendar"
label="Fathom Meetings"
isSelected={tools["FathomMeetings"].id === editor.getCurrentToolId()}
/>
)}
{tools["ImageGen"] && (
<TldrawUiMenuItem
{...tools["ImageGen"]}
@ -1134,6 +721,7 @@ export function CustomToolbar() {
isSelected={tools["Multmux"].id === editor.getCurrentToolId()}
/>
)}
{/* MycelialIntelligence moved to permanent floating bar */}
{/* Share Location tool removed for now */}
{/* Refresh All ObsNotes Button */}
{(() => {

View File

@ -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<string>("")
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 (
<div
className="focus-lock-indicator"
style={{
position: "fixed",
top: "60px",
left: "50%",
transform: "translateX(-50%)",
zIndex: 9999,
display: "flex",
alignItems: "center",
gap: "12px",
padding: "10px 16px",
backgroundColor: "rgba(0, 0, 0, 0.85)",
color: "white",
borderRadius: "8px",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)",
fontFamily: "system-ui, -apple-system, sans-serif",
fontSize: "14px",
backdropFilter: "blur(8px)",
}}
>
<span style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<span>
Focused on:{" "}
<strong style={{ color: "#60a5fa" }}>{shapeName || "Shape"}</strong>
</span>
</span>
<button
onClick={() => unlockCameraFocus(editor)}
style={{
display: "flex",
alignItems: "center",
gap: "6px",
padding: "6px 12px",
backgroundColor: "#3b82f6",
color: "white",
border: "none",
borderRadius: "6px",
cursor: "pointer",
fontSize: "13px",
fontWeight: 500,
transition: "background-color 0.2s",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = "#2563eb")
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = "#3b82f6")
}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 9.9-1" />
</svg>
Unlock View
</button>
<span
style={{
color: "#9ca3af",
fontSize: "12px",
marginLeft: "4px",
}}
>
(Press Esc)
</span>
</div>
)
}

View File

@ -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 }) => (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill={isListening ? "#10b981" : "currentColor"}
style={{
filter: isListening ? 'drop-shadow(0 0 8px #10b981)' : 'none',
transition: 'all 0.3s ease'
}}
>
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5z"/>
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg>
)
// Send icon component
const SendIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
</svg>
)
// Expand/collapse icon
const ExpandIcon = ({ isExpanded }: { isExpanded: boolean }) => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
style={{
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.3s ease'
}}
>
<path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/>
</svg>
)
// 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<HTMLInputElement>(null)
const chatContainerRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [prompt, setPrompt] = useState("")
const [isExpanded, setIsExpanded] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [isListening, setIsListening] = useState(false)
const [conversationHistory, setConversationHistory] = useState<ConversationMessage[]>([])
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 (
<div
ref={containerRef}
className="mycelial-intelligence-bar"
style={{
position: 'fixed',
top: '10px',
left: '50%',
transform: 'translateX(-50%)',
width: barWidth,
height,
zIndex: 99999,
pointerEvents: 'auto',
}}
onPointerEnter={() => setIsHovering(true)}
onPointerLeave={() => setIsHovering(false)}
>
<div
style={{
width: '100%',
height: '100%',
background: isHovering ? colors.backgroundHover : colors.background,
borderRadius: isExpanded ? '20px' : '24px',
border: `1px solid ${isHovering ? colors.borderHover : colors.border}`,
boxShadow: isHovering ? colors.shadowHover : colors.shadow,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
fontFamily: "'Inter', 'SF Pro Display', -apple-system, sans-serif",
transition: 'all 0.3s ease',
backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(16px)',
}}
>
{/* Collapsed: Single-line prompt bar */}
{!isExpanded && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '6px 10px 6px 14px',
height: '100%',
}}>
{/* Mushroom + Brain icon */}
<span style={{
fontSize: '16px',
opacity: 0.9,
flexShrink: 0,
}}>
🍄🧠
</span>
{/* Input field */}
<input
ref={inputRef}
type="text"
value={prompt}
onChange={(e) => 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 && (
<span style={{
color: ACCENT_COLOR,
fontSize: '11px',
whiteSpace: 'nowrap',
opacity: 0.8,
}}>
{Math.round(indexingProgress)}%
</span>
)}
{/* Voice button (compact) */}
{isVoiceSupported && (
<button
onClick={(e) => {
e.stopPropagation()
toggleVoice()
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
width: '34px',
height: '34px',
borderRadius: '50%',
border: 'none',
background: isRecording
? `rgba(16, 185, 129, 0.15)`
: 'transparent',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: isRecording ? ACCENT_COLOR : colors.textMuted,
transition: 'all 0.2s ease',
flexShrink: 0,
}}
title={isRecording ? "Stop recording" : "Voice input"}
>
<MicrophoneIcon isListening={isRecording} isDark={isDark} />
</button>
)}
{/* Send button (compact, pill shape) */}
<button
onClick={(e) => {
e.stopPropagation()
handleSubmit()
}}
onPointerDown={(e) => e.stopPropagation()}
disabled={!prompt.trim() || isLoading}
style={{
height: '34px',
padding: '0 14px',
borderRadius: '17px',
border: 'none',
background: prompt.trim() && !isLoading
? ACCENT_COLOR
: colors.inputBg,
cursor: prompt.trim() && !isLoading ? 'pointer' : 'default',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: prompt.trim() && !isLoading ? 'white' : colors.textMuted,
transition: 'all 0.2s ease',
flexShrink: 0,
opacity: prompt.trim() && !isLoading ? 1 : 0.5,
}}
title="Send"
>
<SendIcon />
</button>
{/* Expand button if there's history */}
{conversationHistory.length > 0 && (
<button
onClick={(e) => {
e.stopPropagation()
toggleExpand()
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
width: '34px',
height: '34px',
borderRadius: '50%',
border: 'none',
background: 'transparent',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: ACCENT_COLOR,
transition: 'all 0.2s',
flexShrink: 0,
}}
title="View conversation"
>
<ExpandIcon isExpanded={false} />
</button>
)}
</div>
)}
{/* Expanded: Header + Conversation + Input */}
{isExpanded && (
<>
{/* Header */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 14px',
borderBottom: `1px solid ${colors.border}`,
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
}}>
<span style={{ fontSize: '16px' }}>🍄🧠</span>
<span style={{
color: colors.text,
fontSize: '13px',
fontWeight: 500,
letterSpacing: '-0.01em',
}}>
<span style={{ fontStyle: 'italic', opacity: 0.85 }}>ask your mycelial intelligence anything about this workspace</span>
</span>
{isIndexing && (
<span style={{
color: colors.textMuted,
fontSize: '11px',
marginLeft: '4px',
}}>
Indexing... {Math.round(indexingProgress)}%
</span>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation()
toggleExpand()
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
color: colors.textMuted,
padding: '4px',
borderRadius: '6px',
display: 'flex',
alignItems: 'center',
transition: 'all 0.2s',
}}
title="Collapse"
>
<ExpandIcon isExpanded={true} />
</button>
</div>
{/* Conversation area */}
<div
ref={chatContainerRef}
style={{
flex: 1,
overflowY: 'auto',
padding: '12px',
display: 'flex',
flexDirection: 'column',
gap: '10px',
}}
onWheel={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{conversationHistory.length === 0 && !streamingResponse && (
<div style={{
color: colors.textMuted,
fontSize: '13px',
textAlign: 'center',
padding: '20px 16px',
}}>
I can search, summarize, and find connections across all your workspace content.
</div>
)}
{conversationHistory.map((msg, idx) => (
<div
key={idx}
style={{
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
maxWidth: '85%',
padding: '8px 12px',
borderRadius: msg.role === 'user' ? '14px 14px 4px 14px' : '14px 14px 14px 4px',
backgroundColor: msg.role === 'user' ? colors.userBubble : colors.assistantBubble,
border: `1px solid ${msg.role === 'user' ? 'rgba(16, 185, 129, 0.2)' : colors.border}`,
color: colors.text,
fontSize: '13px',
lineHeight: '1.5',
whiteSpace: 'pre-wrap',
}}
>
{msg.content}
</div>
))}
{/* Streaming response */}
{streamingResponse && (
<div style={{
alignSelf: 'flex-start',
maxWidth: '85%',
padding: '8px 12px',
borderRadius: '14px 14px 14px 4px',
backgroundColor: colors.assistantBubble,
border: `1px solid ${colors.border}`,
color: colors.text,
fontSize: '13px',
lineHeight: '1.5',
whiteSpace: 'pre-wrap',
}}>
{streamingResponse}
{isLoading && (
<span style={{
display: 'inline-block',
width: '2px',
height: '14px',
backgroundColor: ACCENT_COLOR,
marginLeft: '2px',
animation: 'blink 1s infinite',
}} />
)}
</div>
)}
{/* Loading indicator */}
{isLoading && !streamingResponse && (
<div style={{
alignSelf: 'flex-start',
display: 'flex',
gap: '5px',
padding: '8px 12px',
}}>
<span className="loading-dot" style={{ backgroundColor: ACCENT_COLOR }} />
<span className="loading-dot" style={{ backgroundColor: ACCENT_COLOR, animationDelay: '0.2s' }} />
<span className="loading-dot" style={{ backgroundColor: ACCENT_COLOR, animationDelay: '0.4s' }} />
</div>
)}
</div>
{/* Input area (expanded) */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '10px 12px',
borderTop: `1px solid ${colors.border}`,
}}>
<input
ref={inputRef}
type="text"
value={prompt}
onChange={(e) => 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 && (
<button
onClick={(e) => {
e.stopPropagation()
toggleVoice()
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
width: '36px',
height: '36px',
borderRadius: '50%',
border: `1px solid ${isRecording ? ACCENT_COLOR : colors.inputBorder}`,
background: isRecording
? `rgba(16, 185, 129, 0.1)`
: colors.inputBg,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: isRecording ? ACCENT_COLOR : colors.textMuted,
transition: 'all 0.2s ease',
boxShadow: isRecording ? `0 0 12px rgba(16, 185, 129, 0.3)` : 'none',
flexShrink: 0,
}}
title={isRecording ? "Stop recording" : "Start voice input"}
>
<MicrophoneIcon isListening={isRecording} isDark={isDark} />
</button>
)}
{/* Send button */}
<button
onClick={(e) => {
e.stopPropagation()
handleSubmit()
}}
onPointerDown={(e) => e.stopPropagation()}
disabled={!prompt.trim() || isLoading}
style={{
width: '36px',
height: '36px',
borderRadius: '50%',
border: 'none',
background: prompt.trim() && !isLoading
? ACCENT_COLOR
: colors.inputBg,
cursor: prompt.trim() && !isLoading ? 'pointer' : 'not-allowed',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: prompt.trim() && !isLoading ? 'white' : colors.textMuted,
transition: 'all 0.2s ease',
boxShadow: prompt.trim() && !isLoading
? '0 2px 8px rgba(16, 185, 129, 0.3)'
: 'none',
flexShrink: 0,
}}
title="Send message"
>
<SendIcon />
</button>
</div>
</>
)}
</div>
{/* CSS animations */}
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.loading-dot {
width: 6px;
height: 6px;
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out;
}
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
`}</style>
</div>
)
}

View File

@ -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<HTMLDivElement>(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 }) => (
<SettingsDialog
onClose={() => {
dialogClose()
removeDialog("api-keys")
checkApiKeys()
}}
/>
),
})
}
const handleSetVault = () => {
window.dispatchEvent(new CustomEvent('open-obsidian-browser'))
onClose()
}
return (
<div className="settings-modal-overlay">
<div className="settings-modal" ref={modalRef}>
<div className="settings-modal-header">
<h2>Settings</h2>
<button className="settings-close-btn" onClick={onClose} title="Close (Esc)">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
</svg>
</button>
</div>
<div className="settings-tabs">
<button
className={`settings-tab ${activeTab === 'general' ? 'active' : ''}`}
onClick={() => setActiveTab('general')}
>
General
</button>
<button
className={`settings-tab ${activeTab === 'ai' ? 'active' : ''}`}
onClick={() => setActiveTab('ai')}
>
AI Models
</button>
<button
className={`settings-tab ${activeTab === 'integrations' ? 'active' : ''}`}
onClick={() => setActiveTab('integrations')}
>
Integrations
</button>
</div>
<div className="settings-content">
{activeTab === 'general' && (
<div className="settings-section">
<div className="settings-item">
<div className="settings-item-info">
<span className="settings-item-label">Appearance</span>
<span className="settings-item-description">Toggle between light and dark mode</span>
</div>
<button
className="settings-toggle-btn"
onClick={onToggleDarkMode}
>
<span className="toggle-icon">{isDarkMode ? '🌙' : '☀️'}</span>
<span>{isDarkMode ? 'Dark' : 'Light'}</span>
</button>
</div>
</div>
)}
{activeTab === 'ai' && (
<div className="settings-section">
<div className="settings-item">
<div className="settings-item-info">
<span className="settings-item-label">AI API Keys</span>
<span className="settings-item-description">
{hasApiKey ? 'Your AI models are configured and ready' : 'Configure API keys to use AI features'}
</span>
</div>
<div className="settings-item-status">
<span className={`status-badge ${hasApiKey ? 'success' : 'warning'}`}>
{hasApiKey ? 'Configured' : 'Not Set'}
</span>
</div>
</div>
<button className="settings-action-btn" onClick={openApiKeysDialog}>
{hasApiKey ? 'Manage API Keys' : 'Add API Keys'}
</button>
</div>
)}
{activeTab === 'integrations' && (
<div className="settings-section">
{/* Obsidian Vault */}
<div className="settings-item">
<div className="settings-item-info">
<span className="settings-item-label">Obsidian Vault</span>
<span className="settings-item-description">
{session.obsidianVaultName
? `Connected: ${session.obsidianVaultName}`
: 'Connect your Obsidian vault to import notes'}
</span>
</div>
<div className="settings-item-status">
<span className={`status-badge ${session.obsidianVaultName ? 'success' : 'warning'}`}>
{session.obsidianVaultName ? 'Connected' : 'Not Set'}
</span>
</div>
</div>
<button className="settings-action-btn" onClick={handleSetVault}>
{session.obsidianVaultName ? 'Change Vault' : 'Connect Vault'}
</button>
<div className="settings-divider" />
{/* Fathom API */}
<div className="settings-item">
<div className="settings-item-info">
<span className="settings-item-label">Fathom Meetings</span>
<span className="settings-item-description">
{hasFathomApiKey
? 'Your Fathom account is connected'
: 'Connect Fathom to import meeting recordings'}
</span>
</div>
<div className="settings-item-status">
<span className={`status-badge ${hasFathomApiKey ? 'success' : 'warning'}`}>
{hasFathomApiKey ? 'Connected' : 'Not Set'}
</span>
</div>
</div>
{showFathomApiKeyInput ? (
<div className="settings-input-group">
<input
type="password"
value={fathomApiKeyInput}
onChange={(e) => 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
/>
<div className="settings-input-actions">
<button
className="settings-btn-sm primary"
onClick={() => {
if (fathomApiKeyInput.trim()) {
saveFathomApiKey(fathomApiKeyInput.trim(), session.username)
setHasFathomApiKey(true)
setShowFathomApiKeyInput(false)
setFathomApiKeyInput('')
}
}}
>
Save
</button>
<button
className="settings-btn-sm"
onClick={() => {
setShowFathomApiKeyInput(false)
setFathomApiKeyInput('')
}}
>
Cancel
</button>
</div>
</div>
) : (
<div className="settings-button-group">
<button
className="settings-action-btn"
onClick={() => {
setShowFathomApiKeyInput(true)
const currentKey = getFathomApiKey(session.username)
if (currentKey) setFathomApiKeyInput(currentKey)
}}
>
{hasFathomApiKey ? 'Change API Key' : 'Add API Key'}
</button>
{hasFathomApiKey && (
<button
className="settings-action-btn secondary"
onClick={() => {
removeFathomApiKey(session.username)
setHasFathomApiKey(false)
}}
>
Disconnect
</button>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
)
}

View File

@ -5,6 +5,26 @@ const MAX_HISTORY = 10 // Keep last 10 camera positions
const frameObservers = new Map<string, ResizeObserver>()
// 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', () => {

View File

@ -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 (
<div className="custom-people-menu">
{/* Current user avatar */}
<div
title={`${myUserName} (you)`}
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
backgroundColor: myUserColor,
border: '2px solid white',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
cursor: 'default',
}}
/>
{/* Other users */}
{collaborators.map((presence) => (
<div
key={presence.id}
title={`${presence.userName || 'Anonymous'}`}
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
backgroundColor: presence.color,
border: '2px solid white',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
marginLeft: '-8px',
cursor: 'default',
}}
/>
))}
{/* User count badge */}
{collaborators.length > 0 && (
<span style={{
fontSize: '12px',
color: 'var(--color-text-1)',
marginLeft: '4px',
}}>
{collaborators.length + 1}
</span>
)}
</div>
)
}
// Custom SharePanel that shows the people menu
function CustomSharePanel() {
return (
<div className="tlui-share-zone" draggable={false}>
<CustomPeopleMenu />
</div>
)
}
// Combined InFrontOfCanvas component for floating UI elements
function CustomInFrontOfCanvas() {
return (
<>
<MycelialIntelligenceBar />
<FocusLockIndicator />
</>
)
}
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"],

View File

@ -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",

View File

@ -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) {
@ -1418,6 +1420,53 @@ 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