Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
137 changes: 111 additions & 26 deletions src/components/ChatArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -33,11 +42,18 @@ export default function ChatArea({ conversationId }: Props) {
}, [showConversationSettings, setShowSystemPrompt, setShowParameters]);

if (!conversationId) {
return <WelcomeScreen />;
return (
<>
<JoinRoomModal />
<WelcomeScreen />
</>
);
}

return (
<div className="flex-1 flex flex-col min-w-0 min-h-0 bg-slate-900 relative">
<JoinRoomModal />
{isSharedChat && <CollaborationBar />}
<MessageList
messages={conversation?.messages ?? []}
conversationId={conversationId ?? undefined}
Expand All @@ -64,34 +80,103 @@ export default function ChatArea({ conversationId }: Props) {

{/* Conversation Settings side panel */}
{showConversationSettings && conversationId && (
<div
className="absolute inset-0 z-40 flex items-stretch justify-end"
onClick={() => setShowConversationSettings(false)}
>
<div
className="w-80 flex flex-col bg-slate-900 border-l border-slate-700 shadow-2xl overflow-y-auto"
onClick={(e) => e.stopPropagation()}
<SidePanel title="Conversation Settings" onClose={() => setShowConversationSettings(false)}>
<SystemPromptEditor conversationId={conversationId} />
<ParameterControls
conversationId={conversationId}
defaultParams={settings?.defaultParameters ?? { temperature: 0.7, topP: 1, maxTokens: 4096 }}
/>
</SidePanel>
)}

{/* Room Settings side panel (host only, shown when gear is clicked) */}
{showRoomSettings && isSharedChat && (
<SidePanel title="Room Settings" onClose={() => setShowRoomSettings(false)}>
<RoomSettingsContent
aiMode={aiMode}
participants={participants}
myId={myId}
onSetAiMode={(mode) => {
useCollaborationStore.getState().setAiMode(mode);
window.api?.collab?.send({ type: 'set_ai_mode', mode });
}}
/>
</SidePanel>
)}
</div>
);
}

// ── 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 (
<div className="flex flex-col gap-6 px-4 py-4">
{/* AI mode */}
<div>
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-2">AI Model</p>
<div className="flex flex-col gap-2">
<button
onClick={() => 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
<span className={`block text-xs mt-0.5 font-normal ${aiMode === 'own' ? 'text-blue-200' : 'text-slate-500'}`}>
Every participant calls AI with their own API key
</span>
</button>
<button
onClick={() => 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'
}`}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700 flex-shrink-0">
<h2 className="text-sm font-semibold text-slate-100">Conversation Settings</h2>
<button
onClick={() => setShowConversationSettings(false)}
className="text-slate-400 hover:text-slate-200 transition-colors p-1 rounded hover:bg-slate-700"
title="Close"
Host relays AI
<span className={`block text-xs mt-0.5 font-normal ${aiMode === 'host' ? 'text-blue-200' : 'text-slate-500'}`}>
All AI calls run on the host's machine — guests need no API key
</span>
</button>
</div>
</div>

{/* Participants */}
<div>
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-2">
Participants ({participants.length})
</p>
<div className="flex flex-col gap-1.5">
{participants.map((p) => (
<div key={p.id} className="flex items-center gap-2.5 py-1">
<span
className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold text-white flex-shrink-0"
style={{ backgroundColor: p.color }}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{p.name.charAt(0).toUpperCase()}
</span>
<span className="text-sm text-slate-200">
{p.name}
{p.id === myId && <span className="text-slate-500 ml-1.5 text-xs">(you)</span>}
</span>
</div>
<SystemPromptEditor conversationId={conversationId} />
<ParameterControls
conversationId={conversationId}
defaultParams={settings?.defaultParameters ?? { temperature: 0.7, topP: 1, maxTokens: 4096 }}
/>
</div>
))}
{participants.length === 0 && (
<p className="text-xs text-slate-500">No participants yet</p>
)}
</div>
)}
</div>
</div>
);
}
175 changes: 175 additions & 0 deletions src/components/CollaborationBar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col bg-[#1a2744] border-b border-[#2d4a7a] text-sm">
{/* Main bar */}
<div className="flex items-center gap-2 px-3 py-1.5">
{/* Connection indicator */}
<span
className={`w-2 h-2 rounded-full flex-shrink-0 ${
isConnecting ? 'bg-yellow-400 animate-pulse' :
isConnected ? 'bg-emerald-400' :
'bg-red-500'
}`}
title={isConnecting ? 'Connecting…' : isConnected ? 'Connected' : connectionError ?? 'Disconnected'}
/>

{/* Participant avatars */}
<div className="flex items-center -space-x-1.5">
{participants.map((p) => (
<div
key={p.id}
className="w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white flex-shrink-0 ring-2 ring-[#1a2744]"
style={{ backgroundColor: p.color }}
title={p.name + (p.id === myId ? ' (you)' : '')}
>
{p.name.charAt(0).toUpperCase()}
</div>
))}
</div>

{/* Participant names */}
<span className="text-[#94a3b8] hidden sm:block truncate max-w-[140px]">
{participants.map((p) => p.id === myId ? `${p.name} (you)` : p.name).join(', ')}
</span>

{/* Lock status / turn indicator */}
<div className="flex items-center gap-1.5 ml-1">
{hasLock ? (
<>
<span className="text-emerald-400 font-medium">Your turn</span>
<button
onClick={handleReleaseLock}
className="px-2 py-0.5 rounded text-xs bg-[#263349] hover:bg-[#334155] text-[#f8fafc] transition-colors"
>
Done
</button>
</>
) : lockHolder ? (
<span className="text-[#94a3b8]">
<span className="font-medium" style={{ color: participants.find((p) => p.id === lockHolder)?.color }}>
{lockHolderName}
</span>
{' '}is typing…
</span>
) : (
<button
onClick={handleRequestLock}
disabled={requesting || !isConnected}
className="px-2.5 py-0.5 rounded text-xs bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium transition-colors"
>
{requesting ? 'Requesting…' : 'Request turn'}
</button>
)}
</div>

{/* Spacer */}
<div className="flex-1" />

{/* Invite link */}
{inviteUrl && (
<button
onClick={handleCopyInvite}
className="flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-[#263349] hover:bg-[#334155] text-[#94a3b8] hover:text-[#f8fafc] transition-colors"
title="Copy invite link"
>
{copied ? (
<svg className="w-3.5 h-3.5 text-emerald-400" fill="none" viewBox="0 0 16 16" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 8l4 4 6-6" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 16 16" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M10 3H6a3 3 0 000 6h1m-1 4h4a3 3 0 000-6h-1" />
</svg>
)}
{copied ? 'Copied!' : 'Invite'}
</button>
)}

{/* Room settings gear (host only) */}
{isHost && (
<button
onClick={() => setShowRoomSettings(!showRoomSettings)}
className={`p-1 rounded transition-colors ${showRoomSettings ? 'text-white bg-[#334155]' : 'text-[#94a3b8] hover:text-white hover:bg-[#263349]'}`}
title="Room settings"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 16 16" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 10a2 2 0 100-4 2 2 0 000 4z"/>
<path d="M13.3 6.7l-.9-.5a5 5 0 000-1.4l.9-.5a1 1 0 00.4-1.4l-.5-.8a1 1 0 00-1.4-.4l-.9.5a5 5 0 00-1.2-.7V1a1 1 0 00-1-1H7a1 1 0 00-1 1v1.1a5 5 0 00-1.2.7l-.9-.5a1 1 0 00-1.4.4l-.5.8a1 1 0 00.4 1.4l.9.5a5 5 0 000 1.4l-.9.5a1 1 0 00-.4 1.4l.5.8a1 1 0 001.4.4l.9-.5a5 5 0 001.2.7V15a1 1 0 001 1h1a1 1 0 001-1v-1.1a5 5 0 001.2-.7l.9.5a1 1 0 001.4-.4l.5-.8a1 1 0 00-.4-1.4z"/>
</svg>
</button>
)}

{/* Leave */}
<button
onClick={handleLeave}
className="px-2 py-0.5 rounded text-xs bg-[#263349] hover:bg-red-900/40 text-[#94a3b8] hover:text-red-400 transition-colors"
title="Leave room"
>
Leave
</button>
</div>
</div>
);
}
Loading
Loading