2026-02-13 23:15:12 -08:00
import { useEffect , useMemo , useRef , useState } from "preact/hooks" ;
2026-02-14 01:10:27 -08:00
import { Globe2 , LogOut , MessageSquare , Plus , Search , SendHorizontal , Trash2 } from "lucide-preact" ;
2026-02-13 23:15:12 -08:00
import { Button } from "@/components/ui/button" ;
import { Input } from "@/components/ui/input" ;
import { Textarea } from "@/components/ui/textarea" ;
import { Separator } from "@/components/ui/separator" ;
2026-02-14 00:22:19 -08:00
import { AuthScreen } from "@/components/auth/auth-screen" ;
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel" ;
import { SearchResultsPanel } from "@/components/search/search-results-panel" ;
2026-02-13 23:15:12 -08:00
import {
createChat ,
2026-02-13 23:49:55 -08:00
createSearch ,
2026-02-14 01:10:27 -08:00
deleteChat ,
deleteSearch ,
2026-02-13 23:15:12 -08:00
getChat ,
2026-02-13 23:49:55 -08:00
getSearch ,
2026-02-13 23:15:12 -08:00
listChats ,
2026-02-13 23:49:55 -08:00
listSearches ,
2026-02-13 23:15:12 -08:00
runCompletion ,
2026-02-14 01:53:34 -08:00
runSearchStream ,
2026-02-13 23:15:12 -08:00
type ChatDetail ,
type ChatSummary ,
type CompletionRequestMessage ,
2026-02-13 23:49:55 -08:00
type SearchDetail ,
type SearchSummary ,
2026-02-13 23:15:12 -08:00
} from "@/lib/api" ;
2026-02-14 00:22:19 -08:00
import { useSessionAuth } from "@/hooks/use-session-auth" ;
2026-02-13 23:15:12 -08:00
import { cn } from "@/lib/utils" ;
type Provider = "openai" | "anthropic" | "xai" ;
2026-02-13 23:49:55 -08:00
type SidebarSelection = { kind : "chat" | "search" ; id : string } ;
2026-02-14 00:09:06 -08:00
type DraftSelectionKind = "chat" | "search" ;
2026-02-13 23:49:55 -08:00
type SidebarItem = SidebarSelection & {
title : string ;
updatedAt : string ;
createdAt : string ;
} ;
2026-02-14 01:10:27 -08:00
type ContextMenuState = {
item : SidebarSelection ;
x : number ;
y : number ;
} ;
2026-02-13 23:15:12 -08:00
const PROVIDER_DEFAULT_MODELS : Record < Provider , string > = {
openai : "gpt-4.1-mini" ,
anthropic : "claude-3-5-sonnet-latest" ,
xai : "grok-3-mini" ,
} ;
function getChatTitle ( chat : Pick < ChatSummary , "title" > , messages? : ChatDetail [ "messages" ] ) {
if ( chat . title ? . trim ( ) ) return chat . title . trim ( ) ;
const firstUserMessage = messages ? . find ( ( m ) = > m . role === "user" ) ? . content . trim ( ) ;
if ( firstUserMessage ) return firstUserMessage . slice ( 0 , 48 ) ;
return "New chat" ;
}
2026-02-13 23:49:55 -08:00
function getSearchTitle ( search : Pick < SearchSummary , "title" | "query" > ) {
if ( search . title ? . trim ( ) ) return search . title . trim ( ) ;
if ( search . query ? . trim ( ) ) return search . query . trim ( ) . slice ( 0 , 64 ) ;
return "New search" ;
}
function buildSidebarItems ( chats : ChatSummary [ ] , searches : SearchSummary [ ] ) : SidebarItem [ ] {
const items : SidebarItem [ ] = [
. . . chats . map ( ( chat ) = > ( {
kind : "chat" as const ,
id : chat.id ,
title : getChatTitle ( chat ) ,
updatedAt : chat.updatedAt ,
createdAt : chat.createdAt ,
} ) ) ,
. . . searches . map ( ( search ) = > ( {
kind : "search" as const ,
id : search.id ,
title : getSearchTitle ( search ) ,
updatedAt : search.updatedAt ,
createdAt : search.createdAt ,
} ) ) ,
] ;
return items . sort ( ( a , b ) = > new Date ( b . updatedAt ) . getTime ( ) - new Date ( a . updatedAt ) . getTime ( ) ) ;
}
2026-02-13 23:15:12 -08:00
function formatDate ( value : string ) {
return new Intl . DateTimeFormat ( undefined , {
month : "short" ,
day : "numeric" ,
hour : "numeric" ,
minute : "2-digit" ,
} ) . format ( new Date ( value ) ) ;
}
export default function App() {
2026-02-14 00:22:19 -08:00
const {
authTokenInput ,
setAuthTokenInput ,
isCheckingSession ,
isSigningIn ,
isAuthenticated ,
authMode ,
authError ,
handleAuthFailure : baseHandleAuthFailure ,
handleSignIn ,
logout ,
} = useSessionAuth ( ) ;
2026-02-13 23:15:12 -08:00
const [ chats , setChats ] = useState < ChatSummary [ ] > ( [ ] ) ;
2026-02-13 23:49:55 -08:00
const [ searches , setSearches ] = useState < SearchSummary [ ] > ( [ ] ) ;
const [ selectedItem , setSelectedItem ] = useState < SidebarSelection | null > ( null ) ;
2026-02-13 23:15:12 -08:00
const [ selectedChat , setSelectedChat ] = useState < ChatDetail | null > ( null ) ;
2026-02-13 23:49:55 -08:00
const [ selectedSearch , setSelectedSearch ] = useState < SearchDetail | null > ( null ) ;
2026-02-14 00:09:06 -08:00
const [ draftKind , setDraftKind ] = useState < DraftSelectionKind | null > ( null ) ;
2026-02-13 23:49:55 -08:00
const [ isLoadingCollections , setIsLoadingCollections ] = useState ( false ) ;
const [ isLoadingSelection , setIsLoadingSelection ] = useState ( false ) ;
2026-02-13 23:15:12 -08:00
const [ isSending , setIsSending ] = useState ( false ) ;
const [ composer , setComposer ] = useState ( "" ) ;
const [ provider , setProvider ] = useState < Provider > ( "openai" ) ;
const [ model , setModel ] = useState ( PROVIDER_DEFAULT_MODELS . openai ) ;
const [ error , setError ] = useState < string | null > ( null ) ;
const transcriptEndRef = useRef < HTMLDivElement > ( null ) ;
2026-02-14 01:10:27 -08:00
const contextMenuRef = useRef < HTMLDivElement > ( null ) ;
2026-02-14 01:53:34 -08:00
const searchRunAbortRef = useRef < AbortController | null > ( null ) ;
const searchRunCounterRef = useRef ( 0 ) ;
2026-02-14 01:10:27 -08:00
const [ contextMenu , setContextMenu ] = useState < ContextMenuState | null > ( null ) ;
2026-02-13 23:15:12 -08:00
2026-02-13 23:49:55 -08:00
const sidebarItems = useMemo ( ( ) = > buildSidebarItems ( chats , searches ) , [ chats , searches ] ) ;
2026-02-14 00:22:19 -08:00
const resetWorkspaceState = ( ) = > {
2026-02-13 23:15:12 -08:00
setChats ( [ ] ) ;
2026-02-13 23:49:55 -08:00
setSearches ( [ ] ) ;
setSelectedItem ( null ) ;
2026-02-13 23:15:12 -08:00
setSelectedChat ( null ) ;
2026-02-13 23:49:55 -08:00
setSelectedSearch ( null ) ;
2026-02-14 00:09:06 -08:00
setDraftKind ( null ) ;
2026-02-14 00:22:19 -08:00
setComposer ( "" ) ;
setError ( null ) ;
} ;
const handleAuthFailure = ( message : string ) = > {
baseHandleAuthFailure ( message ) ;
resetWorkspaceState ( ) ;
2026-02-13 23:15:12 -08:00
} ;
2026-02-13 23:49:55 -08:00
const refreshCollections = async ( preferredSelection? : SidebarSelection ) = > {
setIsLoadingCollections ( true ) ;
2026-02-13 23:15:12 -08:00
try {
2026-02-13 23:49:55 -08:00
const [ nextChats , nextSearches ] = await Promise . all ( [ listChats ( ) , listSearches ( ) ] ) ;
const nextItems = buildSidebarItems ( nextChats , nextSearches ) ;
2026-02-13 23:15:12 -08:00
setChats ( nextChats ) ;
2026-02-13 23:49:55 -08:00
setSearches ( nextSearches ) ;
2026-02-13 23:15:12 -08:00
2026-02-13 23:49:55 -08:00
setSelectedItem ( ( current ) = > {
const hasItem = ( candidate : SidebarSelection | null ) = > {
if ( ! candidate ) return false ;
return nextItems . some ( ( item ) = > item . kind === candidate . kind && item . id === candidate . id ) ;
} ;
if ( preferredSelection && hasItem ( preferredSelection ) ) {
return preferredSelection ;
2026-02-13 23:15:12 -08:00
}
2026-02-13 23:49:55 -08:00
if ( hasItem ( current ) ) {
2026-02-13 23:15:12 -08:00
return current ;
}
2026-02-13 23:49:55 -08:00
const first = nextItems [ 0 ] ;
return first ? { kind : first.kind , id : first.id } : null ;
2026-02-13 23:15:12 -08:00
} ) ;
} catch ( err ) {
const message = err instanceof Error ? err.message : String ( err ) ;
if ( message . includes ( "bearer token" ) ) {
handleAuthFailure ( message ) ;
} else {
setError ( message ) ;
}
} finally {
2026-02-13 23:49:55 -08:00
setIsLoadingCollections ( false ) ;
2026-02-13 23:15:12 -08:00
}
} ;
const refreshChat = async ( chatId : string ) = > {
2026-02-13 23:49:55 -08:00
setIsLoadingSelection ( true ) ;
2026-02-13 23:15:12 -08:00
try {
const chat = await getChat ( chatId ) ;
setSelectedChat ( chat ) ;
2026-02-13 23:49:55 -08:00
setSelectedSearch ( null ) ;
2026-02-13 23:15:12 -08:00
} catch ( err ) {
const message = err instanceof Error ? err.message : String ( err ) ;
if ( message . includes ( "bearer token" ) ) {
handleAuthFailure ( message ) ;
} else {
setError ( message ) ;
}
} finally {
2026-02-13 23:49:55 -08:00
setIsLoadingSelection ( false ) ;
}
} ;
const refreshSearch = async ( searchId : string ) = > {
setIsLoadingSelection ( true ) ;
try {
const search = await getSearch ( searchId ) ;
setSelectedSearch ( search ) ;
setSelectedChat ( null ) ;
} catch ( err ) {
const message = err instanceof Error ? err.message : String ( err ) ;
if ( message . includes ( "bearer token" ) ) {
handleAuthFailure ( message ) ;
} else {
setError ( message ) ;
}
} finally {
setIsLoadingSelection ( false ) ;
2026-02-13 23:15:12 -08:00
}
} ;
useEffect ( ( ) = > {
if ( ! isAuthenticated ) return ;
2026-02-13 23:49:55 -08:00
void refreshCollections ( ) ;
2026-02-13 23:15:12 -08:00
} , [ isAuthenticated ] ) ;
2026-02-13 23:49:55 -08:00
const selectedKey = selectedItem ? ` ${ selectedItem . kind } : ${ selectedItem . id } ` : null ;
2026-02-13 23:15:12 -08:00
useEffect ( ( ) = > {
if ( ! isAuthenticated ) {
setSelectedChat ( null ) ;
2026-02-13 23:49:55 -08:00
setSelectedSearch ( null ) ;
2026-02-13 23:15:12 -08:00
return ;
}
2026-02-13 23:49:55 -08:00
if ( ! selectedItem ) {
2026-02-13 23:15:12 -08:00
setSelectedChat ( null ) ;
2026-02-13 23:49:55 -08:00
setSelectedSearch ( null ) ;
2026-02-13 23:15:12 -08:00
return ;
}
2026-02-13 23:49:55 -08:00
if ( selectedItem . kind === "chat" ) {
void refreshChat ( selectedItem . id ) ;
return ;
}
void refreshSearch ( selectedItem . id ) ;
} , [ isAuthenticated , selectedKey ] ) ;
2026-02-13 23:15:12 -08:00
useEffect ( ( ) = > {
2026-02-14 00:09:06 -08:00
if ( draftKind === "search" || selectedItem ? . kind === "search" ) return ;
2026-02-13 23:15:12 -08:00
transcriptEndRef . current ? . scrollIntoView ( { behavior : "smooth" , block : "end" } ) ;
2026-02-14 00:09:06 -08:00
} , [ draftKind , selectedChat ? . messages . length , isSending , selectedItem ? . kind ] ) ;
2026-02-13 23:15:12 -08:00
2026-02-14 01:53:34 -08:00
useEffect ( ( ) = > {
return ( ) = > {
searchRunAbortRef . current ? . abort ( ) ;
searchRunAbortRef . current = null ;
} ;
} , [ ] ) ;
2026-02-13 23:15:12 -08:00
const messages = selectedChat ? . messages ? ? [ ] ;
2026-02-13 23:49:55 -08:00
const selectedChatSummary = useMemo ( ( ) = > {
if ( ! selectedItem || selectedItem . kind !== "chat" ) return null ;
return chats . find ( ( chat ) = > chat . id === selectedItem . id ) ? ? null ;
} , [ chats , selectedItem ] ) ;
const selectedSearchSummary = useMemo ( ( ) = > {
if ( ! selectedItem || selectedItem . kind !== "search" ) return null ;
return searches . find ( ( search ) = > search . id === selectedItem . id ) ? ? null ;
} , [ searches , selectedItem ] ) ;
const selectedTitle = useMemo ( ( ) = > {
2026-02-14 00:09:06 -08:00
if ( draftKind === "chat" ) return "New chat" ;
if ( draftKind === "search" ) return "New search" ;
2026-02-13 23:49:55 -08:00
if ( ! selectedItem ) return "Sybil" ;
if ( selectedItem . kind === "chat" ) {
if ( selectedChat ) return getChatTitle ( selectedChat , selectedChat . messages ) ;
if ( selectedChatSummary ) return getChatTitle ( selectedChatSummary ) ;
return "New chat" ;
}
if ( selectedSearch ) return getSearchTitle ( selectedSearch ) ;
if ( selectedSearchSummary ) return getSearchTitle ( selectedSearchSummary ) ;
return "New search" ;
2026-02-14 00:09:06 -08:00
} , [ draftKind , selectedChat , selectedChatSummary , selectedItem , selectedSearch , selectedSearchSummary ] ) ;
2026-02-13 23:49:55 -08:00
2026-02-14 00:09:06 -08:00
const isSearchMode = draftKind ? draftKind === "search" : selectedItem ? . kind === "search" ;
2026-02-14 00:22:19 -08:00
const isSearchRunning = isSending && isSearchMode ;
2026-02-13 23:15:12 -08:00
2026-02-14 00:09:06 -08:00
const handleCreateChat = ( ) = > {
2026-02-13 23:15:12 -08:00
setError ( null ) ;
2026-02-14 01:10:27 -08:00
setContextMenu ( null ) ;
2026-02-14 00:09:06 -08:00
setDraftKind ( "chat" ) ;
setSelectedItem ( null ) ;
setSelectedChat ( null ) ;
setSelectedSearch ( null ) ;
2026-02-13 23:15:12 -08:00
} ;
2026-02-14 00:09:06 -08:00
const handleCreateSearch = ( ) = > {
2026-02-13 23:15:12 -08:00
setError ( null ) ;
2026-02-14 01:10:27 -08:00
setContextMenu ( null ) ;
2026-02-14 00:09:06 -08:00
setDraftKind ( "search" ) ;
setSelectedItem ( null ) ;
setSelectedChat ( null ) ;
setSelectedSearch ( null ) ;
2026-02-13 23:49:55 -08:00
} ;
2026-02-13 23:15:12 -08:00
2026-02-14 01:10:27 -08:00
const openContextMenu = ( event : MouseEvent , item : SidebarSelection ) = > {
event . preventDefault ( ) ;
const menuWidth = 160 ;
const menuHeight = 40 ;
const padding = 8 ;
const x = Math . min ( event . clientX , window . innerWidth - menuWidth - padding ) ;
const y = Math . min ( event . clientY , window . innerHeight - menuHeight - padding ) ;
setContextMenu ( { item , x : Math.max ( padding , x ) , y : Math.max ( padding , y ) } ) ;
} ;
const handleDeleteFromContextMenu = async ( ) = > {
if ( ! contextMenu || isSending ) return ;
const target = contextMenu . item ;
setContextMenu ( null ) ;
setError ( null ) ;
try {
if ( target . kind === "chat" ) {
await deleteChat ( target . id ) ;
} else {
await deleteSearch ( target . id ) ;
}
await refreshCollections ( ) ;
} catch ( err ) {
const message = err instanceof Error ? err.message : String ( err ) ;
if ( message . includes ( "bearer token" ) ) {
handleAuthFailure ( message ) ;
} else {
setError ( message ) ;
}
}
} ;
useEffect ( ( ) = > {
if ( ! contextMenu ) return ;
const handlePointerDown = ( event : PointerEvent ) = > {
if ( contextMenuRef . current ? . contains ( event . target as Node ) ) return ;
setContextMenu ( null ) ;
} ;
const handleKeyDown = ( event : KeyboardEvent ) = > {
if ( event . key === "Escape" ) setContextMenu ( null ) ;
} ;
window . addEventListener ( "pointerdown" , handlePointerDown ) ;
window . addEventListener ( "keydown" , handleKeyDown ) ;
return ( ) = > {
window . removeEventListener ( "pointerdown" , handlePointerDown ) ;
window . removeEventListener ( "keydown" , handleKeyDown ) ;
} ;
} , [ contextMenu ] ) ;
2026-02-13 23:49:55 -08:00
const handleSendChat = async ( content : string ) = > {
2026-02-14 00:09:06 -08:00
let chatId = draftKind === "chat" ? null : selectedItem ? . kind === "chat" ? selectedItem.id : null ;
2026-02-13 23:15:12 -08:00
2026-02-13 23:49:55 -08:00
if ( ! chatId ) {
const chat = await createChat ( ) ;
chatId = chat . id ;
2026-02-14 00:09:06 -08:00
setDraftKind ( null ) ;
2026-02-13 23:49:55 -08:00
setSelectedItem ( { kind : "chat" , id : chatId } ) ;
setSelectedChat ( {
id : chat.id ,
title : chat.title ,
createdAt : chat.createdAt ,
updatedAt : chat.updatedAt ,
messages : [ ] ,
} ) ;
setSelectedSearch ( null ) ;
}
2026-02-13 23:15:12 -08:00
2026-02-13 23:49:55 -08:00
if ( ! chatId ) {
throw new Error ( "Unable to initialize chat" ) ;
}
let baseChat = selectedChat ;
if ( ! baseChat || baseChat . id !== chatId ) {
baseChat = await getChat ( chatId ) ;
}
const optimisticUserMessage = {
id : ` temp-user- ${ Date . now ( ) } ` ,
createdAt : new Date ( ) . toISOString ( ) ,
role : "user" as const ,
content ,
name : null ,
} ;
const optimisticAssistantMessage = {
id : ` temp-assistant- ${ Date . now ( ) } ` ,
createdAt : new Date ( ) . toISOString ( ) ,
role : "assistant" as const ,
content : "" ,
name : null ,
} ;
setSelectedChat ( ( current ) = > {
if ( ! current || current . id !== chatId ) return current ;
return {
. . . current ,
messages : [ . . . current . messages , optimisticUserMessage , optimisticAssistantMessage ] ,
2026-02-13 23:15:12 -08:00
} ;
2026-02-13 23:49:55 -08:00
} ) ;
const requestMessages : CompletionRequestMessage [ ] = [
. . . baseChat . messages . map ( ( message ) = > ( {
role : message.role ,
content : message.content ,
. . . ( message . name ? { name : message.name } : { } ) ,
} ) ) ,
{
role : "user" ,
content ,
} ,
] ;
await runCompletion ( {
chatId ,
provider ,
model : model.trim ( ) ,
messages : requestMessages ,
} ) ;
2026-02-13 23:15:12 -08:00
2026-02-13 23:49:55 -08:00
await Promise . all ( [ refreshCollections ( { kind : "chat" , id : chatId } ) , refreshChat ( chatId ) ] ) ;
} ;
const handleSendSearch = async ( query : string ) = > {
2026-02-14 01:53:34 -08:00
const runId = ++ searchRunCounterRef . current ;
searchRunAbortRef . current ? . abort ( ) ;
const abortController = new AbortController ( ) ;
searchRunAbortRef . current = abortController ;
2026-02-14 00:09:06 -08:00
let searchId = draftKind === "search" ? null : selectedItem ? . kind === "search" ? selectedItem.id : null ;
2026-02-13 23:49:55 -08:00
if ( ! searchId ) {
2026-02-14 00:09:06 -08:00
const search = await createSearch ( {
query ,
title : query.slice ( 0 , 80 ) ,
} ) ;
2026-02-13 23:49:55 -08:00
searchId = search . id ;
2026-02-14 00:09:06 -08:00
setDraftKind ( null ) ;
2026-02-13 23:49:55 -08:00
setSelectedItem ( { kind : "search" , id : searchId } ) ;
}
if ( ! searchId ) {
throw new Error ( "Unable to initialize search" ) ;
}
2026-02-13 23:56:35 -08:00
const nowIso = new Date ( ) . toISOString ( ) ;
2026-02-13 23:49:55 -08:00
setSelectedSearch ( ( current ) = > {
2026-02-13 23:56:35 -08:00
if ( ! current || current . id !== searchId ) {
return {
id : searchId ,
title : query.slice ( 0 , 80 ) ,
query ,
createdAt : nowIso ,
updatedAt : nowIso ,
requestId : null ,
latencyMs : null ,
error : null ,
2026-02-14 00:14:10 -08:00
answerText : null ,
answerRequestId : null ,
answerCitations : null ,
answerError : null ,
2026-02-13 23:56:35 -08:00
results : [ ] ,
} ;
}
2026-02-13 23:49:55 -08:00
return {
. . . current ,
title : query.slice ( 0 , 80 ) ,
query ,
error : null ,
2026-02-13 23:56:35 -08:00
latencyMs : null ,
2026-02-14 00:14:10 -08:00
answerText : null ,
answerRequestId : null ,
answerCitations : null ,
answerError : null ,
2026-02-13 23:49:55 -08:00
results : [ ] ,
2026-02-13 23:15:12 -08:00
} ;
2026-02-13 23:49:55 -08:00
} ) ;
2026-02-13 23:15:12 -08:00
2026-02-14 01:53:34 -08:00
try {
await runSearchStream (
searchId ,
{
query ,
title : query.slice ( 0 , 80 ) ,
type : "auto" ,
numResults : 10 ,
} ,
{
onSearchResults : ( payload ) = > {
if ( runId !== searchRunCounterRef . current ) return ;
setSelectedSearch ( ( current ) = > {
if ( ! current || current . id !== searchId ) return current ;
return {
. . . current ,
requestId : payload.requestId ? ? current . requestId ,
error : null ,
results : payload.results ,
} ;
} ) ;
} ,
onSearchError : ( payload ) = > {
if ( runId !== searchRunCounterRef . current ) return ;
setSelectedSearch ( ( current ) = > {
if ( ! current || current . id !== searchId ) return current ;
return { . . . current , error : payload.error } ;
} ) ;
} ,
onAnswer : ( payload ) = > {
if ( runId !== searchRunCounterRef . current ) return ;
setSelectedSearch ( ( current ) = > {
if ( ! current || current . id !== searchId ) return current ;
return {
. . . current ,
answerText : payload.answerText ,
answerRequestId : payload.answerRequestId ,
answerCitations : payload.answerCitations ,
answerError : null ,
} ;
} ) ;
} ,
onAnswerError : ( payload ) = > {
if ( runId !== searchRunCounterRef . current ) return ;
setSelectedSearch ( ( current ) = > {
if ( ! current || current . id !== searchId ) return current ;
return { . . . current , answerError : payload.error } ;
} ) ;
} ,
onDone : ( payload ) = > {
if ( runId !== searchRunCounterRef . current ) return ;
setSelectedSearch ( payload . search ) ;
setSelectedChat ( null ) ;
} ,
onError : ( payload ) = > {
if ( runId !== searchRunCounterRef . current ) return ;
setError ( payload . message ) ;
} ,
} ,
{ signal : abortController.signal }
) ;
} catch ( err ) {
if ( abortController . signal . aborted ) return ;
throw err ;
} finally {
if ( runId === searchRunCounterRef . current ) {
searchRunAbortRef . current = null ;
}
}
2026-02-13 23:15:12 -08:00
2026-02-13 23:49:55 -08:00
await refreshCollections ( { kind : "search" , id : searchId } ) ;
} ;
const handleSend = async ( ) = > {
const content = composer . trim ( ) ;
if ( ! content || isSending ) return ;
setComposer ( "" ) ;
setError ( null ) ;
setIsSending ( true ) ;
2026-02-13 23:15:12 -08:00
2026-02-13 23:49:55 -08:00
try {
if ( isSearchMode ) {
await handleSendSearch ( content ) ;
} else {
await handleSendChat ( content ) ;
}
2026-02-13 23:15:12 -08:00
} catch ( err ) {
const message = err instanceof Error ? err.message : String ( err ) ;
if ( message . includes ( "bearer token" ) ) {
handleAuthFailure ( message ) ;
} else {
setError ( message ) ;
}
2026-02-13 23:49:55 -08:00
if ( selectedItem ? . kind === "chat" ) {
await refreshChat ( selectedItem . id ) ;
}
if ( selectedItem ? . kind === "search" ) {
await refreshSearch ( selectedItem . id ) ;
2026-02-13 23:15:12 -08:00
}
} finally {
setIsSending ( false ) ;
}
} ;
const handleLogout = ( ) = > {
2026-02-14 01:10:27 -08:00
setContextMenu ( null ) ;
2026-02-14 00:22:19 -08:00
logout ( ) ;
resetWorkspaceState ( ) ;
2026-02-13 23:15:12 -08:00
} ;
if ( isCheckingSession ) {
return (
2026-02-13 23:20:57 -08:00
< div className = "flex h-full items-center justify-center" >
2026-02-13 23:15:12 -08:00
< p className = "text-sm text-muted-foreground" > Checking session . . . < / p >
< / div >
) ;
}
if ( ! isAuthenticated ) {
return (
2026-02-14 00:22:19 -08:00
< AuthScreen
authTokenInput = { authTokenInput }
setAuthTokenInput = { setAuthTokenInput }
isSigningIn = { isSigningIn }
authError = { authError }
onSignIn = { handleSignIn }
/ >
2026-02-13 23:15:12 -08:00
) ;
}
return (
2026-02-14 00:35:01 -08:00
< div className = "h-full" >
< div className = "flex h-full w-full overflow-hidden bg-background" >
2026-02-14 20:34:10 -08:00
< aside className = "flex w-80 shrink-0 flex-col border-r bg-[hsl(272_34%_14%)]" >
2026-02-13 23:49:55 -08:00
< div className = "grid grid-cols-2 gap-2 p-3" >
< Button className = "justify-start gap-2" onClick = { handleCreateChat } >
2026-02-13 23:15:12 -08:00
< Plus className = "h-4 w-4" / >
New chat
< / Button >
2026-02-13 23:49:55 -08:00
< Button className = "justify-start gap-2" variant = "secondary" onClick = { handleCreateSearch } >
< Search className = "h-4 w-4" / >
New search
< / Button >
2026-02-13 23:15:12 -08:00
< / div >
< Separator / >
< div className = "flex-1 overflow-y-auto p-2" >
2026-02-14 00:22:19 -08:00
{ isLoadingCollections && sidebarItems . length === 0 ? < p className = "px-2 py-3 text-sm text-muted-foreground" > Loading conversations . . . < / p > : null }
2026-02-13 23:49:55 -08:00
{ ! isLoadingCollections && sidebarItems . length === 0 ? (
2026-02-13 23:15:12 -08:00
< div className = "flex h-full flex-col items-center justify-center gap-2 p-5 text-center text-sm text-muted-foreground" >
< MessageSquare className = "h-5 w-5" / >
2026-02-13 23:49:55 -08:00
Start a chat or run your first search .
2026-02-13 23:15:12 -08:00
< / div >
) : null }
2026-02-13 23:49:55 -08:00
{ sidebarItems . map ( ( item ) = > {
const active = selectedItem ? . kind === item . kind && selectedItem . id === item . id ;
2026-02-13 23:15:12 -08:00
return (
< button
2026-02-13 23:49:55 -08:00
key = { ` ${ item . kind } - ${ item . id } ` }
2026-02-13 23:15:12 -08:00
className = { cn (
"mb-1 w-full rounded-lg px-3 py-2 text-left transition" ,
2026-02-14 20:34:10 -08:00
active ? "bg-violet-500/30 text-violet-100" : "text-violet-200/85 hover:bg-violet-500/15"
2026-02-13 23:15:12 -08:00
) }
2026-02-14 00:09:06 -08:00
onClick = { ( ) = > {
2026-02-14 01:10:27 -08:00
setContextMenu ( null ) ;
2026-02-14 00:09:06 -08:00
setDraftKind ( null ) ;
setSelectedItem ( { kind : item.kind , id : item.id } ) ;
} }
2026-02-14 01:10:27 -08:00
onContextMenu = { ( event ) = > openContextMenu ( event , { kind : item.kind , id : item.id } ) }
2026-02-13 23:15:12 -08:00
type = "button"
>
2026-02-13 23:49:55 -08:00
< div className = "flex items-center gap-2" >
{ item . kind === "chat" ? < MessageSquare className = "h-3.5 w-3.5" / > : < Search className = "h-3.5 w-3.5" / > }
< p className = "truncate text-sm font-medium" > { item . title } < / p >
< / div >
2026-02-14 20:34:10 -08:00
< p className = { cn ( "mt-1 text-xs" , active ? "text-violet-100/90" : "text-violet-300/60" ) } > { formatDate ( item . updatedAt ) } < / p >
2026-02-13 23:15:12 -08:00
< / button >
) ;
} ) }
< / div >
< / aside >
< main className = "flex min-w-0 flex-1 flex-col" >
< header className = "flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3" >
< div >
2026-02-13 23:49:55 -08:00
< h1 className = "text-sm font-semibold md:text-base" > { selectedTitle } < / h1 >
2026-02-13 23:15:12 -08:00
< p className = "text-xs text-muted-foreground" >
Sybil Web { authMode ? ` ( ${ authMode === "open" ? "open mode" : "token mode" } ) ` : "" }
2026-02-13 23:49:55 -08:00
{ isSearchMode ? " • Exa Search" : "" }
2026-02-13 23:15:12 -08:00
< / p >
< / div >
< div className = "flex w-full max-w-xl items-center gap-2 md:w-auto" >
2026-02-13 23:49:55 -08:00
{ ! isSearchMode ? (
< >
< select
className = "h-9 rounded-md border border-input bg-background px-2 text-sm"
value = { provider }
onChange = { ( event ) = > {
const nextProvider = event . currentTarget . value as Provider ;
setProvider ( nextProvider ) ;
setModel ( PROVIDER_DEFAULT_MODELS [ nextProvider ] ) ;
} }
disabled = { isSending }
>
< option value = "openai" > OpenAI < / option >
< option value = "anthropic" > Anthropic < / option >
< option value = "xai" > xAI < / option >
< / select >
2026-02-14 00:22:19 -08:00
< Input value = { model } onInput = { ( event ) = > setModel ( event . currentTarget . value ) } placeholder = "Model" disabled = { isSending } / >
2026-02-13 23:49:55 -08:00
< / >
) : (
< div className = "flex h-9 items-center rounded-md border border-input px-3 text-sm text-muted-foreground" >
< Globe2 className = "mr-2 h-4 w-4" / >
Search mode
< / div >
) }
2026-02-13 23:15:12 -08:00
< Button variant = "outline" size = "sm" onClick = { handleLogout } >
< LogOut className = "mr-1 h-4 w-4" / >
Logout
< / Button >
< / div >
< / header >
< div className = "flex-1 overflow-y-auto px-3 py-6 md:px-10" >
2026-02-13 23:49:55 -08:00
{ ! isSearchMode ? (
2026-02-14 00:22:19 -08:00
< ChatMessagesPanel messages = { messages } isLoading = { isLoadingSelection } isSending = { isSending } / >
2026-02-13 23:49:55 -08:00
) : (
2026-02-14 00:22:19 -08:00
< SearchResultsPanel search = { selectedSearch } isLoading = { isLoadingSelection } isRunning = { isSearchRunning } / >
2026-02-13 23:49:55 -08:00
) }
2026-02-13 23:15:12 -08:00
< div ref = { transcriptEndRef } / >
< / div >
< footer className = "border-t p-3 md:p-4" >
< div className = "mx-auto max-w-3xl rounded-xl border bg-background p-2 shadow-sm" >
< Textarea
rows = { 3 }
value = { composer }
onInput = { ( event ) = > setComposer ( event . currentTarget . value ) }
onKeyDown = { ( event ) = > {
if ( event . key === "Enter" && ! event . shiftKey ) {
event . preventDefault ( ) ;
void handleSend ( ) ;
}
} }
2026-02-13 23:49:55 -08:00
placeholder = { isSearchMode ? "Search the web" : "Message Sybil" }
2026-02-13 23:15:12 -08:00
className = "resize-none border-0 shadow-none focus-visible:ring-0"
disabled = { isSending }
/ >
< div className = "flex items-center justify-between px-2 pb-1" >
2026-02-14 00:22:19 -08:00
{ error ? < p className = "text-xs text-red-600" > { error } < / p > : < span className = "text-xs text-muted-foreground" > { isSearchMode ? "Enter to search" : "Enter to send" } < / span > }
2026-02-13 23:15:12 -08:00
< Button onClick = { ( ) = > void handleSend ( ) } size = "icon" disabled = { isSending || ! composer . trim ( ) } >
2026-02-13 23:49:55 -08:00
{ isSearchMode ? < Search className = "h-4 w-4" / > : < SendHorizontal className = "h-4 w-4" / > }
2026-02-13 23:15:12 -08:00
< / Button >
< / div >
< / div >
< / footer >
< / main >
< / div >
2026-02-14 01:10:27 -08:00
{ contextMenu ? (
< div
ref = { contextMenuRef }
className = "fixed z-50 min-w-40 rounded-md border border-border bg-background p-1 shadow-md"
style = { { left : contextMenu.x , top : contextMenu.y } }
onContextMenu = { ( event ) = > event . preventDefault ( ) }
>
< button
type = "button"
className = "flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm text-red-600 transition hover:bg-muted disabled:text-muted-foreground"
onClick = { ( ) = > void handleDeleteFromContextMenu ( ) }
disabled = { isSending }
>
< Trash2 className = "h-3.5 w-3.5" / >
Delete
< / button >
< / div >
) : null }
2026-02-13 23:15:12 -08:00
< / div >
) ;
}