From 3a8d8bc1de5036727c72c781126f4261ee782f89 Mon Sep 17 00:00:00 2001 From: "Andrew @ Conduit" Date: Sun, 24 May 2026 22:01:54 -0500 Subject: [PATCH 1/3] feat: live collaboration rooms, SidePanel, sharing self-hosting - Add CollaborationBar, JoinRoomModal, SidePanel components - Add collaborationStore (room state, participants, lock, aiMode, isLocalHost) - Add useCollaboration hook for WebSocket room management - Migrate conversation/room settings to SidePanel overlay - Fix infinite render loop: replace object selectors with scalar selectors - Add optimistic setAiMode with server echo - StatusBar: context-aware live indicator (active vs other chat) - SettingsPanel Sharing tab: multi-section (Shared Links / Live / Self-Hosting) - Add AppSettings.selfHosting.shareServerUrl for custom relay server URL - ChatArea: isLocalHost flag for immediate gear icon visibility - uiStore: add showRoomSettings toggle - types: AppSettings.selfHosting, extend IPC collab API - lib/export: conversation export utilities --- package-lock.json | 4 +- package.json | 2 +- src/components/ChatArea.tsx | 137 ++++++++--- src/components/CollaborationBar.tsx | 178 +++++++++++++++ src/components/JoinRoomModal.tsx | 134 +++++++++++ src/components/SettingsPanel.tsx | 338 +++++++++++++++++++++++++++- src/components/SidePanel.tsx | 54 +++++ src/components/StatusBar.tsx | 179 ++++++++++++++- src/env.d.ts | 26 +++ src/hooks/useCollaboration.ts | 69 ++++++ src/lib/export.ts | 107 +++++++++ src/stores/collaborationStore.ts | 167 ++++++++++++++ src/stores/uiStore.ts | 4 + src/types.ts | 50 ++++ 14 files changed, 1418 insertions(+), 31 deletions(-) create mode 100644 src/components/CollaborationBar.tsx create mode 100644 src/components/JoinRoomModal.tsx create mode 100644 src/components/SidePanel.tsx create mode 100644 src/hooks/useCollaboration.ts create mode 100644 src/stores/collaborationStore.ts diff --git a/package-lock.json b/package-lock.json index a2db0e1..251683d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@openconduit/core", - "version": "2.0.0-alpha.12", + "version": "2.0.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@openconduit/core", - "version": "2.0.0-alpha.12", + "version": "2.0.0-beta.1", "license": "AGPL-3.0", "dependencies": { "@codemirror/commands": "^6.10.3", diff --git a/package.json b/package.json index 242232f..34da274 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openconduit/core", - "version": "2.0.0-alpha.12", + "version": "2.0.0-beta.1", "description": "Shared UI components, stores, hooks, and service interface for OpenConduit", "main": "src/index.ts", "types": "src/index.ts", diff --git a/src/components/ChatArea.tsx b/src/components/ChatArea.tsx index 77ed907..0da88bb 100644 --- a/src/components/ChatArea.tsx +++ b/src/components/ChatArea.tsx @@ -5,10 +5,14 @@ import InputBar from './InputBar'; import SystemPromptEditor from './SystemPromptEditor'; import ParameterControls from './ParameterControls'; import ContextWarningBanner from './ContextWarningBanner'; +import { CollaborationBar } from './CollaborationBar'; +import { JoinRoomModal } from './JoinRoomModal'; +import { SidePanel } from './SidePanel'; import { useChat } from '../hooks/useChat'; import { useSettingsStore } from '../stores/settingsStore'; import { useConversationStore } from '../stores/conversationStore'; import { useUiStore } from '../stores/uiStore'; +import { useCollaborationStore } from '../stores/collaborationStore'; interface Props { conversationId: string | null; @@ -18,7 +22,12 @@ export default function ChatArea({ conversationId }: Props) { const { conversation, isStreaming, isCompacting, sendMessage, abortStream, approveToolCall, sendAnswers, compactContext, trimOldMessages } = useChat(conversationId); const { settings } = useSettingsStore(); const { clearMessages } = useConversationStore(); - const { activeConversationId, showConversationSettings, setShowConversationSettings, setShowSystemPrompt, setShowParameters } = useUiStore(); + const { activeConversationId, showConversationSettings, setShowConversationSettings, setShowSystemPrompt, setShowParameters, showRoomSettings, setShowRoomSettings } = useUiStore(); + const collabConversationId = useCollaborationStore((s) => s.conversationId); + const aiMode = useCollaborationStore((s) => s.aiMode); + const participants = useCollaborationStore((s) => s.participants); + const myId = useCollaborationStore((s) => s.myId); + const isSharedChat = !!conversationId && conversationId === collabConversationId; const handleClear = () => { if (activeConversationId) clearMessages(activeConversationId); @@ -33,11 +42,18 @@ export default function ChatArea({ conversationId }: Props) { }, [showConversationSettings, setShowSystemPrompt, setShowParameters]); if (!conversationId) { - return ; + return ( + <> + + + + ); } return (
+ + {isSharedChat && } setShowConversationSettings(false)} - > -
e.stopPropagation()} + setShowConversationSettings(false)}> + + + + )} + + {/* Room Settings side panel (host only, shown when gear is clicked) */} + {showRoomSettings && isSharedChat && ( + setShowRoomSettings(false)}> + { + useCollaborationStore.getState().setAiMode(mode); + window.api?.collab?.send({ type: 'set_ai_mode', mode }); + }} + /> + + )} +
+ ); +} + +// ── Room Settings panel content ──────────────────────────────────────────────── + +interface RoomSettingsContentProps { + aiMode: 'own' | 'host'; + participants: Array<{ id: string; name: string; color: string }>; + myId: string | null; + onSetAiMode: (mode: 'own' | 'host') => void; +} + +function RoomSettingsContent({ aiMode, participants, myId, onSetAiMode }: RoomSettingsContentProps) { + return ( +
+ {/* AI mode */} +
+

AI Model

+
+ + +
+
+ + {/* Participants */} +
+

+ Participants ({participants.length}) +

+
+ {participants.map((p) => ( +
+ - - - - + {p.name.charAt(0).toUpperCase()} + + + {p.name} + {p.id === myId && (you)} +
- - -
+ ))} + {participants.length === 0 && ( +

No participants yet

+ )}
- )} +
); } diff --git a/src/components/CollaborationBar.tsx b/src/components/CollaborationBar.tsx new file mode 100644 index 0000000..91769c8 --- /dev/null +++ b/src/components/CollaborationBar.tsx @@ -0,0 +1,178 @@ +/** + * CollaborationBar — shows when the user is in a live collaboration room. + * + * Displays: + * - Participant presence avatars with color rings + * - Lock indicator (whose turn it is to send) + * - "Request turn" / "Release turn" button + * - Invite link copy button + * - Room settings (gear icon) — AI mode toggle for host + * - Leave button + */ + +import React, { useState, useCallback } from 'react'; +import { useCollaborationStore, useHasLock, useIsHost } from '../stores/collaborationStore'; +import { useUiStore } from '../stores/uiStore'; + +interface CollaborationBarProps { + /** Called when the user leaves the room. */ + onLeave?: () => void; +} + +export function CollaborationBar({ onLeave }: CollaborationBarProps) { + const { roomId, inviteUrl, participants, lockHolder, myId, isConnected, isConnecting, connectionError } = useCollaborationStore(); + const hasLock = useHasLock(); + const isHost = useIsHost(); + const { showRoomSettings, setShowRoomSettings } = useUiStore(); + const [copied, setCopied] = useState(false); + const [requesting, setRequesting] = useState(false); + + if (!roomId) return null; + + const lockHolderName = participants.find((p) => p.id === lockHolder)?.name ?? null; + + const handleCopyInvite = useCallback(async () => { + if (!inviteUrl) return; + try { + await navigator.clipboard.writeText(inviteUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { /* ignore */ } + }, [inviteUrl]); + + const handleRequestLock = useCallback(async () => { + if (!window.api?.collab) return; + setRequesting(true); + try { + await window.api.collab.lockRequest(); + } finally { + setRequesting(false); + } + }, []); + + const handleReleaseLock = useCallback(async () => { + await window.api?.collab?.lockRelease(); + }, []); + + const handleLeave = useCallback(async () => { + await window.api?.collab?.leave(); + useCollaborationStore.getState().clearRoom(); + onLeave?.(); + }, [onLeave]); + + const handleSetAiMode = useCallback((mode: 'own' | 'host') => { + window.api?.collab?.send({ type: 'set_ai_mode', mode }); + }, []); + + return ( +
+ {/* Main bar */} +
+ {/* Connection indicator */} + + + {/* Participant avatars */} +
+ {participants.map((p) => ( +
+ {p.name.charAt(0).toUpperCase()} +
+ ))} +
+ + {/* Participant names */} + + {participants.map((p) => p.id === myId ? `${p.name} (you)` : p.name).join(', ')} + + + {/* Lock status / turn indicator */} +
+ {hasLock ? ( + <> + Your turn + + + ) : lockHolder ? ( + + p.id === lockHolder)?.color }}> + {lockHolderName} + + {' '}is typing… + + ) : ( + + )} +
+ + {/* Spacer */} +
+ + {/* Invite link */} + {inviteUrl && ( + + )} + + {/* Room settings gear (host only) */} + {isHost && ( + + )} + + {/* Leave */} + +
+
+ ); +} diff --git a/src/components/JoinRoomModal.tsx b/src/components/JoinRoomModal.tsx new file mode 100644 index 0000000..3ace262 --- /dev/null +++ b/src/components/JoinRoomModal.tsx @@ -0,0 +1,134 @@ +/** + * JoinRoomModal — shown when a deep-link room invite arrives. + * Lets the user pick a display name and colour before joining. + */ + +import React, { useState } from 'react'; +import { useCollaborationStore } from '../stores/collaborationStore'; +import { useConversationStore } from '../stores/conversationStore'; +import { useUiStore } from '../stores/uiStore'; + +const PALETTE = [ + '#3B82F6', '#8B5CF6', '#10B981', '#F59E0B', + '#EF4444', '#06B6D4', '#EC4899', '#84CC16', +]; + +export function JoinRoomModal() { + const { pendingInviteRoomId, clearPendingInvite, setRoom } = useCollaborationStore(); + const [name, setName] = useState(''); + const [color, setColor] = useState(PALETTE[0]); + const [joining, setJoining] = useState(false); + const [error, setError] = useState(null); + + if (!pendingInviteRoomId) return null; + const roomId = pendingInviteRoomId; + + const handleJoin = async () => { + if (joining) return; + setError(null); + const displayName = name.trim() || 'Guest'; + setJoining(true); + + try { + const { conversations, folders, createFolder, addConversation, openTab, updateConversation } = + useConversationStore.getState(); + const uiStore = useUiStore.getState(); + + // Find an existing conversation tied to this room, or create a new one. + let targetConv = conversations.find((c) => c.liveRoomId === roomId) ?? null; + if (!targetConv) { + let sharedFolder = folders.find((f) => f.name === 'Shared' && f.parentId === null); + if (!sharedFolder) sharedFolder = createFolder('Shared'); + targetConv = addConversation({ + title: `${displayName}'s room`, + folderId: sharedFolder.id, + liveRoomId: roomId, + }); + } else if (!targetConv.liveRoomId) { + updateConversation(targetConv.id, { liveRoomId: roomId }); + } + + openTab(targetConv.id); + uiStore.setActiveConversation(targetConv.id); + + await window.api!.collab!.join(roomId, displayName, color); + setRoom(roomId, `https://share.openconduit.ai/rooms/${roomId}`, targetConv.id); + clearPendingInvite(); + } catch { + setError('Could not connect to the room. Check your network and try again.'); + } finally { + setJoining(false); + } + }; + + return ( +
+
+ {/* Header */} +
+

Join live room

+

+ Pick a name other participants will see. +

+
+ + {/* Name input */} +
+ + setName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleJoin()} + placeholder="Guest" + maxLength={32} + autoFocus + className="bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:border-blue-500 transition-colors" + /> +
+ + {/* Colour picker */} +
+ +
+ {PALETTE.map((c) => ( +
+
+ + {/* Error */} + {error && ( +

{error}

+ )} + + {/* Actions */} +
+ + +
+
+
+ ); +} diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 5b880ea..e4ecfdb 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -7,6 +7,7 @@ import { oneDark } from '@codemirror/theme-one-dark'; import { v4 as uuidv4 } from 'uuid'; import { useSettingsStore } from '../stores/settingsStore'; import { useUiStore } from '../stores/uiStore'; +import { useCollaborationStore } from '../stores/collaborationStore'; import { useAnalyticsStore } from '../stores/analyticsStore'; import type { ProviderConfig, McpServerConfig, AppSettings, ProviderType, McpTransport, McpTool, UpdateInfo, FeedbackPayload, RoutingConfig, RoutingTier, RoutingProviderRule, RoutingTaskType, RoutingProfile, SettingsProperty, SettingsStringProperty, SettingsNumberProperty, SettingsButtonProperty, SettingsContribution, ConfigBundle } from '../types'; import { service } from '../services'; @@ -107,8 +108,340 @@ const Icons = { ), + sharing: ( + + + + + ), + selfHosting: ( + + + + ), } as const; +// ─── SharingTab ─────────────────────────────────────────────────────────────── + +interface ShareRecord { id: string; url: string; title: string; createdAt: number; } + +// ─── Sharing: sub-sections ──────────────────────────────────────────────────── + +function SharedLinksSection() { + const [shares, setShares] = useState([]); + const [loading, setLoading] = useState(true); + const [checking, setChecking] = useState(false); + const [deleting, setDeleting] = useState(null); + const [copied, setCopied] = useState(null); + + const available = typeof window !== 'undefined' && !!window.api?.conversation?.listShares; + + useEffect(() => { + if (!available) { setLoading(false); return; } + window.api!.conversation!.listShares().then(async (raw) => { + const list = raw as ShareRecord[]; + const now = Date.now(); + const TTL = 30 * 24 * 60 * 60 * 1000; + + // Instantly remove anything past the 30-day TTL without a network call. + const maybeAlive: ShareRecord[] = []; + const definitelyExpired: ShareRecord[] = []; + for (const s of list) { + if (s.createdAt + TTL <= now) { + definitelyExpired.push(s); + } else { + maybeAlive.push(s); + } + } + for (const s of definitelyExpired) { + window.api!.conversation!.deleteShare(s.id).catch(() => {}); + } + + setShares(maybeAlive); + setLoading(false); + + // Probe the remaining shares in parallel to catch server-side deletions. + if (maybeAlive.length > 0) { + setChecking(true); + const gone: string[] = []; + await Promise.all( + maybeAlive.map(async (s) => { + try { + const res = await fetch(s.url, { + method: 'GET', + signal: AbortSignal.timeout(6000), + }); + if (res.status === 404) gone.push(s.id); + } catch { + // Network error — leave the record intact; it may be transient. + } + }) + ); + if (gone.length > 0) { + for (const id of gone) { + window.api!.conversation!.deleteShare(id).catch(() => {}); + } + setShares((prev) => prev.filter((s) => !gone.includes(s.id))); + } + setChecking(false); + } + }); + }, [available]); + + const handleDelete = async (id: string) => { + if (!available) return; + setDeleting(id); + await window.api!.conversation!.deleteShare(id); + setShares((s) => s.filter((r) => r.id !== id)); + setDeleting(null); + }; + + const handleCopy = async (url: string, id: string) => { + await navigator.clipboard.writeText(url); + setCopied(id); + setTimeout(() => setCopied(null), 2000); + }; + + if (!available) { + return ( +
+ Sharing is only available in the desktop app. +
+ ); + } + + return ( +
+
+

Shared conversations

+

+ Snapshots you've shared from this machine. Links expire 30 days after creation. + Deleting removes the link from the server so it's no longer accessible. +

+
+ + {(loading || checking) && ( +
+ {loading ? 'Loading…' : 'Checking for expired links…'} +
+ )} + + {!loading && !checking && shares.length === 0 && ( +
+ No shared conversations yet. Click Share in the status bar to create one. +
+ )} + + {!loading && !checking && shares.length > 0 && ( +
+ {shares.map((share) => ( +
+
+

{share.title || 'Untitled'}

+

+ {new Date(share.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })} + {' · '} + expires {new Date(share.createdAt + 30 * 24 * 60 * 60 * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} +

+
+ + +
+ ))} +
+ )} +
+ ); +} + +function LiveSection() { + const roomId = useCollaborationStore((s) => s.roomId); + const participants = useCollaborationStore((s) => s.participants); + const conversationId = useCollaborationStore((s) => s.conversationId); + const { setActiveConversation, setShowSettings } = useUiStore(); + + return ( +
+
+

Live Collaboration

+

+ Start a live session to collaborate on a conversation in real time. + Participants connect via a WebSocket room — turns are locked so only one person + sends at a time. The host can optionally relay AI responses to guests who have + no API keys configured. +

+
+ + {roomId ? ( +
+
+
+ + Room active +
+

+ {participants.length} {participants.length === 1 ? 'participant' : 'participants'} connected +

+ +
+
+ ) : ( +
+ No active room. Click Live in the status bar to start one. +
+ )} +
+ ); +} + +function SelfHostingSection({ + settings, + onSave, +}: { + settings: AppSettings; + onSave: (p: Partial) => Promise; +}) { + const [url, setUrl] = useState(settings.selfHosting?.shareServerUrl ?? ''); + const [saved, setSaved] = useState(false); + + const handleSave = async () => { + const trimmed = url.trim(); + await onSave({ selfHosting: { ...settings.selfHosting, shareServerUrl: trimmed || undefined } }); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + }; + + const handleReset = async () => { + setUrl(''); + await onSave({ selfHosting: { ...settings.selfHosting, shareServerUrl: undefined } }); + }; + + return ( +
+
+

Self-Hosting

+

+ Override the default OpenConduit cloud endpoints with your own server. + Leave blank to use the hosted service at share.openconduit.ai. +

+
+ +
+
+ +

+ Base URL of your self-hosted server (e.g. https://my-server.example.com). + The app derives the WebSocket URL automatically. +

+
+ { setUrl(e.target.value); setSaved(false); }} + placeholder="https://share.openconduit.ai" + className="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-xs text-slate-200 placeholder-slate-600 focus:outline-none focus:border-blue-500/60" + /> + + {url && ( + + )} +
+
+ +
+

Deploying your own server

+

The room server is a stateful WebSocket server that manages turn-based locks and broadcasts messages between participants. A Node.js reference implementation is available in the server/ directory of the OpenConduit repository.

+

Your server must implement the same HTTP + WebSocket endpoints as the Cloudflare Worker (POST /rooms, WS /rooms/:id, POST /rooms/:id/seed).

+
+
+
+ ); +} + +type SharingSection = 'links' | 'live' | 'self-hosting'; + +function SharingTab({ + settings, + onSave, + section, + onSection, +}: { + settings: AppSettings; + onSave: (p: Partial) => Promise; + section: SharingSection; + onSection: (s: SharingSection) => void; +}) { + const sections: { id: SharingSection; label: string }[] = [ + { id: 'links', label: 'Shared Links' }, + { id: 'live', label: 'Live' }, + { id: 'self-hosting', label: 'Self-Hosting' }, + ]; + + return ( +
+
+ {sections.map((s) => ( + + ))} +
+ {section === 'links' && } + {section === 'live' && } + {section === 'self-hosting' && } +
+ ); +} + // ─── SettingsPanel ──────────────────────────────────────────────────────────── export default function SettingsPanel({ @@ -125,6 +458,7 @@ export default function SettingsPanel({ const [aiSection, setAiSection] = useState<'providers' | 'mcp' | 'personas' | 'prompts' | 'analytics'>('providers'); const [featuresSection, setFeaturesSection] = useState<'features' | 'labs'>('features'); const [extensionsSection, setExtensionsSection] = useState<'installed' | 'settings'>('installed'); + const [sharingSection, setSharingSection] = useState<'links' | 'live' | 'self-hosting'>('links'); // If something opened settings and requested a specific tab (e.g. MCP gear icon), // jump to that tab and clear the request so it doesn't repeat on re-open. @@ -163,7 +497,8 @@ export default function SettingsPanel({ { id: 'general', label: 'General', icon: Icons.general }, { id: 'ai', label: 'AI', icon: Icons.ai }, { id: 'features', label: 'Features', icon: Icons.features }, - { id: 'updates', label: 'Updates', icon: Icons.updates }, + { id: 'sharing', label: 'Sharing', icon: Icons.sharing }, + { id: 'updates', label: 'Updates', icon: Icons.updates }, { id: 'logging', label: 'Logging', icon: Icons.logging }, { id: 'telemetry', label: 'Telemetry', icon: Icons.telemetry }, { id: 'about', label: 'About', icon: Icons.about }, @@ -281,6 +616,7 @@ export default function SettingsPanel({ onSection={setFeaturesSection} /> )} + {tab === 'sharing' && } {tab === 'updates' && } {tab === 'logging' && } {tab === 'telemetry' && } diff --git a/src/components/SidePanel.tsx b/src/components/SidePanel.tsx new file mode 100644 index 0000000..f169129 --- /dev/null +++ b/src/components/SidePanel.tsx @@ -0,0 +1,54 @@ +/** + * SidePanel — a reusable right-side overlay panel. + * + * Renders an absolute overlay backdrop + a right-anchored slide-in panel. + * Used for Conversation Settings, Room Settings, and any future context panels. + * + * Usage: + * setShow(false)}> + * + * + */ + +import React from 'react'; + +interface SidePanelProps { + title: string; + onClose: () => void; + children: React.ReactNode; + /** Panel width, defaults to "w-80" */ + width?: string; +} + +export function SidePanel({ title, onClose, children, width = 'w-80' }: SidePanelProps) { + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

{title}

+ +
+ + {/* Content */} +
+ {children} +
+
+
+ ); +} diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index 039506e..a7c9eb5 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -1,8 +1,9 @@ -import React, { useMemo, useState, useEffect } from 'react'; +import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react'; import { useConversationStore } from '../stores/conversationStore'; import { useSettingsStore } from '../stores/settingsStore'; import { useUiStore } from '../stores/uiStore'; import { useAnalyticsStore } from '../stores/analyticsStore'; +import { useCollaborationStore } from '../stores/collaborationStore'; import type { ProviderType } from '../types'; import NotificationBell from './NotificationBell'; import { extensionRegistry } from '../extensions/extensionRegistry'; @@ -35,9 +36,22 @@ export default function StatusBar() { const { conversations } = useConversationStore(); const { settings } = useSettingsStore(); const records = useAnalyticsStore((s) => s.records); + const { roomId, inviteUrl, participants, setRoom, clearRoom } = useCollaborationStore(); + const collabConversationId = useCollaborationStore((s) => s.conversationId); + const isOnSharedChat = !!activeConversationId && activeConversationId === collabConversationId; const conv = conversations.find((c) => c.id === activeConversationId); + // ── Share state ────────────────────────────────────────────────────────── + const [shareUrl, setShareUrl] = useState(null); + const [sharing, setSharing] = useState(false); + const [shareCopied, setShareCopied] = useState(false); + const sharePopoverRef = useRef(null); + + // ── Live room state ────────────────────────────────────────────────────── + const [startingRoom, setStartingRoom] = useState(false); + const [roomCopied, setRoomCopied] = useState(false); + // Extension-contributed status bar items const [leftItems, setLeftItems] = useState( () => extensionRegistry.getStatusBarItems('left'), @@ -52,6 +66,59 @@ export default function StatusBar() { }); }, []); + // Close share popover when clicking outside + useEffect(() => { + if (!shareUrl) return; + const handler = (e: MouseEvent) => { + if (sharePopoverRef.current && !sharePopoverRef.current.contains(e.target as Node)) { + setShareUrl(null); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [shareUrl]); + + const handleShare = useCallback(async () => { + if (!conv || sharing || !window.api?.conversation) return; + setSharing(true); + setShareUrl(null); + try { + const { url } = await window.api.conversation.share(conv as unknown); + setShareUrl(url); + } catch { /* silently fail — no credentials / network */ } + finally { setSharing(false); } + }, [conv, sharing]); + + const handleCopyShare = useCallback(async () => { + if (!shareUrl) return; + await navigator.clipboard.writeText(shareUrl); + setShareCopied(true); + setTimeout(() => setShareCopied(false), 2000); + }, [shareUrl]); + + const handleStartRoom = useCallback(async () => { + if (!conv || startingRoom || !window.api?.collab) return; + setStartingRoom(true); + try { + const result = await window.api.collab.create(conv); + await window.api.collab.join(result.roomId, 'You', '#3B82F6'); + setRoom(result.roomId, result.inviteUrl, activeConversationId ?? '', true); + } catch { /* ignore */ } + finally { setStartingRoom(false); } + }, [conv, startingRoom, settings, setRoom]); + + const handleCopyRoomLink = useCallback(async () => { + if (!inviteUrl) return; + await navigator.clipboard.writeText(inviteUrl); + setRoomCopied(true); + setTimeout(() => setRoomCopied(false), 2000); + }, [inviteUrl]); + + const handleLeaveRoom = useCallback(async () => { + await window.api?.collab?.leave(); + clearRoom(); + }, [clearRoom]); + // Sum token usage across all messages in the active conversation const tokens = useMemo(() => { if (!conv) return null; @@ -100,6 +167,116 @@ export default function StatusBar() { role="status" aria-label="Status bar" > + {/* ── Share + Live buttons (desktop only, conversation must be active) ── */} + {conv && ( +
+ + {/* Share button */} + {window.api?.conversation && ( +
+ + + {/* Share URL popover */} + {shareUrl && ( +
+
+ Shared link +
+ +
+
+ {shareUrl} + +
+

+ Anyone with this link can view a read-only snapshot. Expires in 30 days. +

+
+ )} +
+ )} + + {/* Live collaboration button */} + {window.api?.collab && !roomId && ( + + )} + + {/* Active room indicator */} + {roomId && ( +
+ + {isOnSharedChat ? ( + + Live · {participants.length} {participants.length === 1 ? 'person' : 'people'} + + ) : ( + + )} + {isOnSharedChat && inviteUrl && ( + + )} + +
+ )} +
+ )} + {/* Model / routing profile indicator — only when a conversation is active */} {conv && modelLabel && (
diff --git a/src/env.d.ts b/src/env.d.ts index dbb4c62..f2f4661 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1,3 +1,29 @@ /// declare const __APP_VERSION__: string; + +// Minimal surface of window.api that core components need. +// The full declaration lives in the desktop package's renderer/env.d.ts. +declare interface Window { + api?: { + collab?: { + create: (seed?: unknown) => Promise<{ roomId: string; wsUrl: string; inviteUrl: string }>; + join: (roomId: string, name: string, color: string) => Promise; + leave: () => Promise; + send: (event: unknown) => Promise; + lockRequest: () => Promise; + lockRelease: () => Promise; + onEvent: (cb: (event: unknown) => void) => () => void; + onInvite?: (cb: (roomId: string) => void) => () => void; + }; + conversation?: { + share: (conversation: unknown) => Promise<{ id: string; url: string }>; + exportHtml: (conversation: unknown) => Promise; + listShares: () => Promise>; + deleteShare: (id: string) => Promise; + }; + [key: string]: unknown; + }; + /** Exposed by the renderer for HTML export triggered from the main process. */ + __exportConversationHtml?: (conversationJson: string) => string; +} diff --git a/src/hooks/useCollaboration.ts b/src/hooks/useCollaboration.ts new file mode 100644 index 0000000..00886f4 --- /dev/null +++ b/src/hooks/useCollaboration.ts @@ -0,0 +1,69 @@ +/** + * useCollaboration — subscribes to room events from the main process, + * updates collaborationStore, and bridges stream events to conversationStore. + * + * Mount this once in the App root when window.api.collab is available. + */ + +import { useEffect } from 'react'; +import type { CollabServerEvent, Message } from '../types'; +import { useCollaborationStore } from '../stores/collaborationStore'; +import { useConversationStore } from '../stores/conversationStore'; + +export function useCollaboration(activeConversationId: string | null) { + const collabStore = useCollaborationStore(); + const convStore = useConversationStore(); + + useEffect(() => { + if (typeof window === 'undefined' || !window.api?.collab) return; + + const unsubEvents = window.api.collab.onEvent((rawEvent: unknown) => { + const event = rawEvent as CollabServerEvent; + + // Always route to the conversation the room was started from, not the currently active one + const targetConversationId = collabStore.conversationId ?? activeConversationId; + + collabStore.handleEvent(event, (msgEvent) => { + if (!targetConversationId) return; + + switch (msgEvent.type) { + case 'message_add': + convStore.addMessage(targetConversationId, msgEvent.message as Message); + break; + + case 'stream_start': { + const placeholder: Message = { + id: msgEvent.messageId, + role: 'assistant', + content: '', + timestamp: Date.now(), + isStreaming: true, + }; + convStore.addMessage(targetConversationId, placeholder); + break; + } + + case 'stream_chunk': + convStore.appendToMessage(targetConversationId, msgEvent.messageId, msgEvent.delta); + break; + + case 'stream_end': + convStore.updateMessage(targetConversationId, msgEvent.messageId, { + ...(msgEvent.message as Message), + isStreaming: false, + }); + break; + } + }); + }); + + // Handle deep-link join invites: openconduit://join?roomId=… + // Don't auto-join — show JoinRoomModal so user can pick a name first. + const unsubInvite = window.api.collab.onInvite?.((roomId: string) => { + if (collabStore.roomId === roomId) return; + collabStore.setPendingInvite(roomId); + }) ?? (() => {}); + + return () => { unsubEvents(); unsubInvite(); }; + }, [activeConversationId]); +} diff --git a/src/lib/export.ts b/src/lib/export.ts index 43d5f02..a68e1b9 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -48,3 +48,110 @@ export function downloadFile(content: string, filename: string, mimeType: string a.click(); URL.revokeObjectURL(url); } + +// ─── HTML export (self-contained, dark theme) ───────────────────────────────── + +function esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function fmtTs(ts: number): string { + return new Date(ts).toLocaleString('en-US', { + month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit', + }); +} + +function renderMd(raw: string): string { + const e = esc(raw); + const fenced = e.replace(/```([a-z]*)\n?([\s\S]*?)```/g, + (_: string, lang: string, code: string) => + `
${code.trim()}
`); + const inline = fenced.replace(/`([^`\n]+)`/g, '$1'); + const bold = inline.replace(/\*\*([^*]+)\*\*/g, '$1'); + const italic = bold.replace(/\*([^*\n]+)\*/g, '$1'); + return italic.replace(/\n/g, '
'); +} + +const HTML_CSS = `*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} +:root{--bg:#0f172a;--s:#1e293b;--s2:#263349;--b:#334155;--t:#f8fafc;--m:#94a3b8;--p:#3b82f6;--q:#8b5cf6;--a:#06b6d4;--ub:#1d3a5f;--cb:#0f172a;--tb:#1a2744;--thb:#231c3d;--f:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;--mono:'JetBrains Mono','Fira Code',Menlo,monospace} +body{background:var(--bg);color:var(--t);font-family:var(--f);line-height:1.6;min-height:100vh} +a{color:var(--a)} +.ph{background:var(--s);border-bottom:1px solid var(--b);padding:18px 24px;position:sticky;top:0;z-index:10;display:flex;align-items:center;gap:10px} +.logo{font-size:14px;font-weight:700;background:linear-gradient(135deg,var(--p),var(--q));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text} +.sep{color:var(--b)}.ct{font-size:15px;font-weight:600}.cm{font-size:13px;color:var(--m);margin-left:auto} +main{max-width:820px;margin:0 auto;padding:24px 16px 80px;display:flex;flex-direction:column;gap:12px} +.msg{border-radius:12px;overflow:hidden;border:1px solid var(--b)} +.msg.user{background:var(--ub);border-color:#2d4a7a}.msg.assistant{background:var(--s)} +.mh{display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid var(--b);font-size:13px} +.rl{font-weight:600}.msg.user .rl{color:#60a5fa}.msg.assistant .rl{color:#a78bfa} +.mb{font-size:11px;background:var(--s2);border:1px solid var(--b);border-radius:4px;padding:1px 6px;color:var(--m);font-family:var(--mono)} +.ts{margin-left:auto;color:var(--m);font-size:12px} +.mbody{padding:14px}.mc{white-space:pre-wrap;word-break:break-word;font-size:15px;line-height:1.7} +.cb{background:var(--cb);border:1px solid var(--b);border-radius:8px;padding:12px 14px;overflow-x:auto;font-family:var(--mono);font-size:13px;line-height:1.6;margin:8px 0;white-space:pre} +.ic{background:var(--cb);border:1px solid var(--b);border-radius:4px;padding:1px 5px;font-family:var(--mono);font-size:13px} +details.th{background:var(--thb);border:1px solid #3b2d6e;border-radius:8px;margin-bottom:10px;overflow:hidden} +details.th>summary{padding:8px 12px;cursor:pointer;font-size:13px;font-style:italic;color:#c4b5fd;list-style:none} +details.th>summary::before{content:'▶';font-size:10px;margin-right:6px} +details.th[open]>summary::before{transform:rotate(90deg);display:inline-block} +.thc{padding:10px 12px;border-top:1px solid #3b2d6e;font-size:13px;color:#c4b5fd;white-space:pre-wrap} +details.tc{background:var(--tb);border:1px solid #2d4a7a;border-radius:8px;margin:6px 0;overflow:hidden} +details.tc>summary{padding:8px 12px;cursor:pointer;font-size:13px;color:var(--a);list-style:none} +details.tc>summary::before{content:'▶ ⚙ ';font-size:10px} +details.tc[open]>summary::before{content:'▼ ⚙ '} +.tcb{padding:10px 12px;border-top:1px solid #2d4a7a} +.sl{font-size:11px;font-weight:600;color:var(--m);text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px} +footer{text-align:center;padding:24px;font-size:13px;color:var(--m);border-top:1px solid var(--b)}`; + +export function exportAsHtml(conversation: Conversation): string { + const msgs = conversation.messages.filter((m) => m.role !== 'system'); + const count = msgs.length; + + const msgsHtml = msgs.map((msg) => { + const isUser = msg.role === 'user'; + const isAst = msg.role === 'assistant'; + const label = isUser ? 'User' : isAst ? 'Assistant' : msg.role.replace(/_/g, ' '); + const cls = isUser ? 'user' : isAst ? 'assistant' : 'other'; + + let body = ''; + + if (msg.thinking) { + body += `
Thinking…
${esc(msg.thinking)}
`; + } + + if (msg.content) { + body += `
${renderMd(msg.content)}
`; + } + + if (msg.toolCalls?.length) { + for (const tc of msg.toolCalls) { + const inp = esc(JSON.stringify(tc.input, null, 2)); + const res = tc.result !== undefined ? esc(String(tc.result)) : ''; + body += `
${esc(tc.name)}
Input
${inp}
${res ? `
Result
${res}
` : ''}
`; + } + } + + const modelBadge = msg.model ? `${esc(msg.model)}` : ''; + + return `
${label}${modelBadge}${fmtTs(msg.timestamp)}
${body}
`; + }).join('\n'); + + return ` + + + + +${esc(conversation.title)} — OpenConduit + + + +
+ + / + ${esc(conversation.title)} + ${count} message${count === 1 ? '' : 's'} +
+
${msgsHtml}
+ + +`; +} diff --git a/src/stores/collaborationStore.ts b/src/stores/collaborationStore.ts new file mode 100644 index 0000000..dde9aa9 --- /dev/null +++ b/src/stores/collaborationStore.ts @@ -0,0 +1,167 @@ +import { create } from 'zustand'; +import type { CollabParticipant, CollabServerEvent, Message } from '../types'; + +// ─── Palette for auto-assigning participant colors ──────────────────────────── +const PALETTE = [ + '#3B82F6', '#8B5CF6', '#10B981', '#F59E0B', + '#EF4444', '#06B6D4', '#EC4899', '#84CC16', +]; + +export function nextPaletteColor(index: number): string { + return PALETTE[index % PALETTE.length]; +} + +// ─── State ──────────────────────────────────────────────────────────────────── + +export interface CollaborationState { + /** null = not in a room */ + roomId: string | null; + inviteUrl: string | null; + /** The conversationId this room was started from — events always route here. */ + conversationId: string | null; + participants: CollabParticipant[]; + /** participantId who currently holds the send lock */ + lockHolder: string | null; + /** The participantId assigned to this local client by the server */ + myId: string | null; + isConnected: boolean; + /** True between join() and the first 'sync' event */ + isConnecting: boolean; + /** Last connection error message, if any */ + connectionError: string | null; + /** AI model routing mode for the room */ + aiMode: 'own' | 'host'; + /** participantId of the room host (first joiner / creator) */ + hostId: string | null; + /** True when this client created the room (local fallback before server confirms hostId) */ + isLocalHost: boolean; + /** Set when a deep-link invite arrives — shows JoinRoomModal before joining */ + pendingInviteRoomId: string | null; + + // ─── Actions ─────────────────────────────────────────────────────────────── + setRoom: (roomId: string, inviteUrl: string, conversationId: string, isLocalHost?: boolean) => void; + clearRoom: () => void; + setConnecting: (value: boolean) => void; + /** Optimistically update aiMode locally (host-side, before server confirmation). */ + setAiMode: (mode: 'own' | 'host') => void; + setPendingInvite: (roomId: string) => void; + clearPendingInvite: () => void; + handleEvent: ( + event: CollabServerEvent, + /** Called by useCollaboration when message events arrive — updates the conversation store. */ + onMessage: (event: CollabServerEvent) => void, + ) => void; +} + +export const useCollaborationStore = create()((set, get) => ({ + roomId: null, + inviteUrl: null, + conversationId: null, + participants: [], + lockHolder: null, + myId: null, + isConnected: false, + isConnecting: false, + connectionError: null, + aiMode: 'own', + hostId: null, + isLocalHost: false, + pendingInviteRoomId: null, + + setRoom: (roomId, inviteUrl, conversationId, isLocalHost = false) => set({ roomId, inviteUrl, conversationId, isConnecting: true, connectionError: null, isLocalHost }), + clearRoom: () => set({ + roomId: null, + inviteUrl: null, + conversationId: null, + participants: [], + lockHolder: null, + myId: null, + isConnected: false, + isConnecting: false, + connectionError: null, + aiMode: 'own', + hostId: null, + isLocalHost: false, + }), + setConnecting: (value) => set({ isConnecting: value }), + setAiMode: (mode) => set({ aiMode: mode }), + setPendingInvite: (roomId) => set({ pendingInviteRoomId: roomId }), + clearPendingInvite: () => set({ pendingInviteRoomId: null }), + + handleEvent: (event, onMessage) => { + switch (event.type) { + case 'sync': + set({ + participants: event.participants, + lockHolder: event.lockHolder, + myId: event.yourId, + isConnected: true, + isConnecting: false, + connectionError: null, + aiMode: event.aiMode ?? 'own', + hostId: event.hostId ?? null, + }); + break; + + case 'settings_update': + set({ aiMode: event.aiMode, hostId: event.hostId }); + break; + + case 'participant_joined': + set((s) => ({ participants: [...s.participants, event.participant] })); + break; + + case 'participant_left': + set((s) => ({ + participants: s.participants.filter((p) => p.id !== event.participantId), + })); + break; + + case 'lock_granted': + set({ lockHolder: event.participantId }); + break; + + case 'lock_released': + set({ lockHolder: event.nextHolder }); + break; + + case 'lock_denied': + // UI can read connectionError to show a brief toast + break; + + case 'error': + set({ connectionError: event.message, isConnected: false }); + break; + + // Delegate message/stream events to the caller (useCollaboration hook) + case 'message_add': + case 'stream_start': + case 'stream_chunk': + case 'stream_end': + case 'typing': + onMessage(event); + break; + } + }, +})); + +/** Returns true if the local user currently holds the send lock. */ +export function useHasLock(): boolean { + const { myId, lockHolder } = useCollaborationStore(); + return !!myId && myId === lockHolder; +} + +/** Returns true if the local user is the room host. */ +export function useIsHost(): boolean { + const { myId, hostId, isLocalHost } = useCollaborationStore(); + // isLocalHost: set immediately when this client created the room (before server confirms) + // myId === hostId: confirmed by server after sync + return isLocalHost || (!!myId && myId === hostId); +} + +/** Returns the local participant's color from the participants list. */ +export function useMyColor(): string { + const { myId, participants } = useCollaborationStore(); + const me = participants.find((p) => p.id === myId); + return me?.color ?? PALETTE[0]; +} diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts index df173da..b3e28bc 100644 --- a/src/stores/uiStore.ts +++ b/src/stores/uiStore.ts @@ -21,6 +21,8 @@ interface UiState { setSettingsInitialTab: (tab: string | null) => void; showConversationSettings: boolean; setShowConversationSettings: (v: boolean) => void; + showRoomSettings: boolean; + setShowRoomSettings: (v: boolean) => void; sidebarOpen: boolean; setSidebarOpen: (v: boolean) => void; activePanel: ActivityPanel; @@ -116,6 +118,8 @@ export const useUiStore = create()((set, get) => ({ setSettingsInitialTab: (tab) => set({ settingsInitialTab: tab }), showConversationSettings: false, setShowConversationSettings: (v) => set({ showConversationSettings: v }), + showRoomSettings: false, + setShowRoomSettings: (v) => set({ showRoomSettings: v }), sidebarOpen: true, setSidebarOpen: (v) => set({ sidebarOpen: v }), diff --git a/src/types.ts b/src/types.ts index 1fa14f7..9a412ea 100644 --- a/src/types.ts +++ b/src/types.ts @@ -197,6 +197,8 @@ export interface Conversation { * gets a second pass to read and react to the other personas' responses. */ panelDiscussionMode?: boolean; + /** The live collaboration room this conversation is associated with. */ + liveRoomId?: string; } // ─── Token Usage ────────────────────────────────────────────────────────── @@ -436,6 +438,18 @@ export interface AppSettings { syncRemoteToken?: string; /** Extension IDs that have been disabled by the user (e.g. 'openconduit.gitSync'). */ disabledExtensionIds?: string[]; + /** + * Self-hosting configuration — override the default OpenConduit cloud endpoints. + * Leave unset to use the default hosted service (share.openconduit.ai). + */ + selfHosting?: { + /** + * Base HTTP(S) URL of your self-hosted room/share server. + * Example: https://my-openconduit-server.example.com + * The app derives the WebSocket URL automatically (https→wss, http→ws). + */ + shareServerUrl?: string; + }; } // ─── Settings Contribution Schema (#37) ─────────────────────────────────── @@ -784,3 +798,39 @@ export interface FeedbackPayload { appVersion: string; platform: string; } + +// ─── Collaboration Types (Issue #15) ──────────────────────────────────────── + +export interface CollabParticipant { + id: string; + name: string; + color: string; +} + +export type CollabClientEvent = + | { type: "join"; name: string; color: string } + | { type: "leave" } + | { type: "lock_request" } + | { type: "lock_release" } + | { type: "message_add"; message: Message } + | { type: "stream_start"; messageId: string } + | { type: "stream_chunk"; messageId: string; delta: string } + | { type: "stream_end"; messageId: string; message: Message } + | { type: "typing"; isTyping: boolean } + | { type: "set_ai_mode"; mode: "own" | "host" }; + +export type CollabServerEvent = + | { type: "sync"; messages: Message[]; participants: CollabParticipant[]; lockHolder: string | null; yourId: string; aiMode: "own" | "host"; hostId: string | null } + | { type: "participant_joined"; participant: CollabParticipant } + | { type: "participant_left"; participantId: string } + | { type: "lock_granted"; participantId: string } + | { type: "lock_denied"; reason: string } + | { type: "lock_released"; nextHolder: string | null } + | { type: "message_add"; message: Message; participantId: string } + | { type: "stream_start"; messageId: string; participantId: string } + | { type: "stream_chunk"; messageId: string; delta: string; participantId: string } + | { type: "stream_end"; messageId: string; message: Message; participantId: string } + | { type: "typing"; participantId: string; isTyping: boolean } + | { type: "settings_update"; aiMode: "own" | "host"; hostId: string | null } + | { type: "error"; message: string }; + From 03edfc6bb0f1f64b79920578c133733781c8c171 Mon Sep 17 00:00:00 2001 From: "Andrew @ Conduit" Date: Sun, 24 May 2026 22:07:18 -0500 Subject: [PATCH 2/3] fix: resolve ESLint errors in live-collab components --- src/components/CollaborationBar.tsx | 11 ++++------- src/components/SettingsPanel.tsx | 6 +++--- src/components/StatusBar.tsx | 2 +- src/hooks/useCollaboration.ts | 8 ++++---- src/stores/collaborationStore.ts | 4 ++-- 5 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/components/CollaborationBar.tsx b/src/components/CollaborationBar.tsx index 91769c8..24c12a3 100644 --- a/src/components/CollaborationBar.tsx +++ b/src/components/CollaborationBar.tsx @@ -27,10 +27,7 @@ export function CollaborationBar({ onLeave }: CollaborationBarProps) { const [copied, setCopied] = useState(false); const [requesting, setRequesting] = useState(false); - if (!roomId) return null; - - const lockHolderName = participants.find((p) => p.id === lockHolder)?.name ?? null; - + // All hooks must be declared before any early return (Rules of Hooks) const handleCopyInvite = useCallback(async () => { if (!inviteUrl) return; try { @@ -60,9 +57,9 @@ export function CollaborationBar({ onLeave }: CollaborationBarProps) { onLeave?.(); }, [onLeave]); - const handleSetAiMode = useCallback((mode: 'own' | 'host') => { - window.api?.collab?.send({ type: 'set_ai_mode', mode }); - }, []); + if (!roomId) return null; + + const lockHolderName = participants.find((p) => p.id === lockHolder)?.name ?? null; return (
diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index e4ecfdb..7434d5f 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -154,7 +154,7 @@ function SharedLinksSection() { } } for (const s of definitelyExpired) { - window.api!.conversation!.deleteShare(s.id).catch(() => {}); + window.api!.conversation!.deleteShare(s.id).catch(() => undefined); } setShares(maybeAlive); @@ -179,7 +179,7 @@ function SharedLinksSection() { ); if (gone.length > 0) { for (const id of gone) { - window.api!.conversation!.deleteShare(id).catch(() => {}); + window.api!.conversation!.deleteShare(id).catch(() => undefined); } setShares((prev) => prev.filter((s) => !gone.includes(s.id))); } @@ -901,7 +901,7 @@ function ProvidersTab({ if (view !== 'list') return; const hasLocal = settings.providers.some((p) => p.type === 'lmstudio' || p.type === 'ollama'); if (!hasLocal) return; - service.models.probe?.().then(setLocalStatus).catch(() => {}); + service.models.probe?.().then(setLocalStatus).catch(() => undefined); }, [view, settings.providers]); const handleSaveProvider = (provider: ProviderConfig) => { diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index a7c9eb5..ce873d8 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -105,7 +105,7 @@ export default function StatusBar() { setRoom(result.roomId, result.inviteUrl, activeConversationId ?? '', true); } catch { /* ignore */ } finally { setStartingRoom(false); } - }, [conv, startingRoom, settings, setRoom]); + }, [conv, startingRoom, setRoom, activeConversationId]); const handleCopyRoomLink = useCallback(async () => { if (!inviteUrl) return; diff --git a/src/hooks/useCollaboration.ts b/src/hooks/useCollaboration.ts index 00886f4..af43103 100644 --- a/src/hooks/useCollaboration.ts +++ b/src/hooks/useCollaboration.ts @@ -11,14 +11,13 @@ import { useCollaborationStore } from '../stores/collaborationStore'; import { useConversationStore } from '../stores/conversationStore'; export function useCollaboration(activeConversationId: string | null) { - const collabStore = useCollaborationStore(); - const convStore = useConversationStore(); - useEffect(() => { if (typeof window === 'undefined' || !window.api?.collab) return; const unsubEvents = window.api.collab.onEvent((rawEvent: unknown) => { const event = rawEvent as CollabServerEvent; + const collabStore = useCollaborationStore.getState(); + const convStore = useConversationStore.getState(); // Always route to the conversation the room was started from, not the currently active one const targetConversationId = collabStore.conversationId ?? activeConversationId; @@ -60,9 +59,10 @@ export function useCollaboration(activeConversationId: string | null) { // Handle deep-link join invites: openconduit://join?roomId=… // Don't auto-join — show JoinRoomModal so user can pick a name first. const unsubInvite = window.api.collab.onInvite?.((roomId: string) => { + const collabStore = useCollaborationStore.getState(); if (collabStore.roomId === roomId) return; collabStore.setPendingInvite(roomId); - }) ?? (() => {}); + }) ?? (() => undefined); return () => { unsubEvents(); unsubInvite(); }; }, [activeConversationId]); diff --git a/src/stores/collaborationStore.ts b/src/stores/collaborationStore.ts index dde9aa9..4c2d277 100644 --- a/src/stores/collaborationStore.ts +++ b/src/stores/collaborationStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import type { CollabParticipant, CollabServerEvent, Message } from '../types'; +import type { CollabParticipant, CollabServerEvent } from '../types'; // ─── Palette for auto-assigning participant colors ──────────────────────────── const PALETTE = [ @@ -53,7 +53,7 @@ export interface CollaborationState { ) => void; } -export const useCollaborationStore = create()((set, get) => ({ +export const useCollaborationStore = create()((set) => ({ roomId: null, inviteUrl: null, conversationId: null, From e2c96385aff9ed00e945edd3000cf6d68ca4525a Mon Sep 17 00:00:00 2001 From: "Andrew @ Conduit" Date: Sun, 24 May 2026 22:09:22 -0500 Subject: [PATCH 3/3] fix: add void return type to catch callbacks (TS7011) --- src/components/SettingsPanel.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 7434d5f..1145fad 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -154,7 +154,7 @@ function SharedLinksSection() { } } for (const s of definitelyExpired) { - window.api!.conversation!.deleteShare(s.id).catch(() => undefined); + window.api!.conversation!.deleteShare(s.id).catch((): void => undefined); } setShares(maybeAlive); @@ -179,7 +179,7 @@ function SharedLinksSection() { ); if (gone.length > 0) { for (const id of gone) { - window.api!.conversation!.deleteShare(id).catch(() => undefined); + window.api!.conversation!.deleteShare(id).catch((): void => undefined); } setShares((prev) => prev.filter((s) => !gone.includes(s.id))); } @@ -901,7 +901,7 @@ function ProvidersTab({ if (view !== 'list') return; const hasLocal = settings.providers.some((p) => p.type === 'lmstudio' || p.type === 'ollama'); if (!hasLocal) return; - service.models.probe?.().then(setLocalStatus).catch(() => undefined); + service.models.probe?.().then(setLocalStatus).catch((): void => undefined); }, [view, settings.providers]); const handleSaveProvider = (provider: ProviderConfig) => {