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
+
+
onSetAiMode('own')}
+ className={`w-full py-2 px-3 rounded-lg text-sm font-medium text-left transition-colors ${
+ aiMode === 'own'
+ ? 'bg-blue-600 text-white'
+ : 'bg-slate-800 text-slate-300 hover:bg-slate-700 hover:text-white'
+ }`}
+ >
+ Each uses own model
+
+ Every participant calls AI with their own API key
+
+
+
onSetAiMode('host')}
+ className={`w-full py-2 px-3 rounded-lg text-sm font-medium text-left transition-colors ${
+ aiMode === 'host'
+ ? 'bg-blue-600 text-white'
+ : 'bg-slate-800 text-slate-300 hover:bg-slate-700 hover:text-white'
+ }`}
>
-
-
Conversation Settings
- setShowConversationSettings(false)}
- className="text-slate-400 hover:text-slate-200 transition-colors p-1 rounded hover:bg-slate-700"
- title="Close"
+ Host relays AI
+
+ All AI calls run on the host's machine — guests need no API key
+
+
+
+
+
+ {/* 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..24c12a3
--- /dev/null
+++ b/src/components/CollaborationBar.tsx
@@ -0,0 +1,175 @@
+/**
+ * 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);
+
+ // All hooks must be declared before any early return (Rules of Hooks)
+ 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]);
+
+ if (!roomId) return null;
+
+ const lockHolderName = participants.find((p) => p.id === lockHolder)?.name ?? null;
+
+ 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
+
+ Done
+
+ >
+ ) : lockHolder ? (
+
+ p.id === lockHolder)?.color }}>
+ {lockHolderName}
+
+ {' '}is typing…
+
+ ) : (
+
+ {requesting ? 'Requesting…' : 'Request turn'}
+
+ )}
+
+
+ {/* Spacer */}
+
+
+ {/* Invite link */}
+ {inviteUrl && (
+
+ {copied ? (
+
+
+
+ ) : (
+
+
+
+ )}
+ {copied ? 'Copied!' : 'Invite'}
+
+ )}
+
+ {/* Room settings gear (host only) */}
+ {isHost && (
+
setShowRoomSettings(!showRoomSettings)}
+ className={`p-1 rounded transition-colors ${showRoomSettings ? 'text-white bg-[#334155]' : 'text-[#94a3b8] hover:text-white hover:bg-[#263349]'}`}
+ title="Room settings"
+ >
+
+
+
+
+
+ )}
+
+ {/* Leave */}
+
+ 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 */}
+
+ Your name
+ 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 */}
+
+
Colour
+
+ {PALETTE.map((c) => (
+ setColor(c)}
+ className={`w-6 h-6 rounded-full transition-transform ${
+ color === c
+ ? 'ring-2 ring-white ring-offset-1 ring-offset-slate-900 scale-110'
+ : 'hover:scale-110'
+ }`}
+ style={{ backgroundColor: c }}
+ aria-label={c}
+ />
+ ))}
+
+
+
+ {/* Error */}
+ {error && (
+
{error}
+ )}
+
+ {/* Actions */}
+
+ clearPendingInvite()}
+ className="px-3 py-1.5 rounded-lg text-sm text-slate-400 hover:text-slate-200 hover:bg-slate-800 transition-colors"
+ >
+ Cancel
+
+
+ {joining ? 'Joining…' : 'Join'}
+
+
+
+
+ );
+}
diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx
index 5b880ea..1145fad 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((): void => undefined);
+ }
+
+ 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((): void => undefined);
+ }
+ 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' })}
+
+
+
handleCopy(share.url, share.id)}
+ title="Copy link"
+ className="flex-shrink-0 text-[10px] px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-slate-200 transition-colors"
+ >
+ {copied === share.id ? '✓ Copied' : 'Copy link'}
+
+
handleDelete(share.id)}
+ disabled={deleting === share.id}
+ title="Delete share"
+ className="flex-shrink-0 text-slate-600 hover:text-red-400 transition-colors disabled:opacity-40 p-1"
+ >
+ {deleting === share.id ? (
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+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
+
+
{
+ if (conversationId) setActiveConversation(conversationId);
+ setShowSettings(false);
+ }}
+ className="self-start text-xs px-3 py-1.5 rounded-lg bg-blue-600/20 text-blue-300 border border-blue-500/30 hover:bg-blue-600/30 transition-colors"
+ >
+ Go to live conversation →
+
+
+
+ ) : (
+
+ 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.
+
+
+
+
+
+
+ Room & Share Server URL
+
+
+ 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"
+ />
+
+ {saved ? '✓ Saved' : 'Save'}
+
+ {url && (
+
+ Reset
+
+ )}
+
+
+
+
+
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) => (
+ onSection(s.id)}
+ className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
+ section === s.id
+ ? 'bg-blue-600/20 text-blue-300 border border-blue-500/30'
+ : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60'
+ }`}
+ >
+ {s.label}
+
+ ))}
+
+ {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' && }
@@ -565,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((): void => undefined);
}, [view, settings.providers]);
const handleSaveProvider = (provider: ProviderConfig) => {
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 */}
+
+
+ {/* Content */}
+
+ {children}
+
+
+
+ );
+}
diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx
index 039506e..ce873d8 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, setRoom, activeConversationId]);
+
+ 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 && (
+
+
+ {sharing ? (
+
+
+
+ ) : (
+
+
+
+
+ )}
+ Share
+
+
+ {/* Share URL popover */}
+ {shareUrl && (
+
+
+
Shared link
+
+
setShareUrl(null)} className="text-slate-600 hover:text-slate-400 text-xs leading-none">✕
+
+
+ {shareUrl}
+
+ {shareCopied ? '✓ Copied' : 'Copy'}
+
+
+
+ Anyone with this link can view a read-only snapshot. Expires in 30 days.
+
+
+ )}
+
+ )}
+
+ {/* Live collaboration button */}
+ {window.api?.collab && !roomId && (
+
+
+
+
+
+
+
+ {startingRoom ? 'Starting…' : 'Live'}
+
+ )}
+
+ {/* Active room indicator */}
+ {roomId && (
+
+
+ {isOnSharedChat ? (
+
+ Live · {participants.length} {participants.length === 1 ? 'person' : 'people'}
+
+ ) : (
+ {
+ const { setActiveConversation } = useUiStore.getState();
+ if (collabConversationId) setActiveConversation(collabConversationId);
+ }}
+ className="text-emerald-700 hover:text-emerald-400 transition-colors font-medium"
+ title="Go to live conversation"
+ >
+ Live (other chat)
+
+ )}
+ {isOnSharedChat && inviteUrl && (
+
+ {roomCopied ? '✓' : '⎘'}
+
+ )}
+
+ ✕
+
+
+ )}
+
+ )}
+
{/* 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..af43103
--- /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) {
+ 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;
+
+ 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) => {
+ const collabStore = useCollaborationStore.getState();
+ if (collabStore.roomId === roomId) return;
+ collabStore.setPendingInvite(roomId);
+ }) ?? (() => undefined);
+
+ 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
+
+
+
+
+ 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..4c2d277
--- /dev/null
+++ b/src/stores/collaborationStore.ts
@@ -0,0 +1,167 @@
+import { create } from 'zustand';
+import type { CollabParticipant, CollabServerEvent } 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) => ({
+ 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 };
+