Introduce chatApp singleton and guarded init

- Implement singleton pattern for chatApp to prevent multiple instances
- Gate initialization with isInitialized to skip repeated init calls
- Add guards for auth and WebSocket connections to avoid overlaps
- Filter non-message payloads and only render content messages
- Improve scroll-to-bottom button visibility and interaction
- Update scrollbar styling, including dark theme rules
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-11-20 14:28:21 -03:00
parent cfbff7f35c
commit 9f1d74b101
4 changed files with 248 additions and 92 deletions

View file

@ -134,16 +134,19 @@ async fn run_axum_server(
let static_path = std::path::Path::new("./web/desktop");
let app = Router::new()
.route("/", get(crate::web_server::index))
.merge(api_router)
.with_state(app_state.clone())
// Static file services must come first to match before other routes
.nest_service("/js", ServeDir::new(static_path.join("js")))
.nest_service("/css", ServeDir::new(static_path.join("css")))
.nest_service("/drive", ServeDir::new(static_path.join("drive")))
.nest_service("/chat", ServeDir::new(static_path.join("chat")))
.nest_service("/mail", ServeDir::new(static_path.join("mail")))
.nest_service("/tasks", ServeDir::new(static_path.join("tasks")))
.fallback_service(ServeDir::new(static_path))
// API routes
.merge(api_router)
.with_state(app_state.clone())
// Root index route - only matches exact "/"
.route("/", get(crate::web_server::index))
// Layers
.layer(cors)
.layer(TraceLayer::new_for_http());

View file

@ -196,7 +196,7 @@ body::before {
}
#messages {
flex: 1;
overflow-y: auto;
overflow-y: scroll;
overflow-x: hidden;
padding: 20px 20px 140px;
max-width: 680px;
@ -431,17 +431,21 @@ footer {
color: var(--bg);
font-size: 18px;
cursor: pointer;
display: none;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
z-index: 90;
opacity: 0;
pointer-events: none;
box-shadow: 0 2px 8px var(--shadow);
}
.scroll-to-bottom.visible {
display: flex;
opacity: 1;
pointer-events: auto;
}
.scroll-to-bottom:hover {
transform: scale(1.1) rotate(180deg);
transform: scale(1.15);
}
.warning-message {
border-radius: 12px;
@ -612,7 +616,7 @@ footer {
opacity: 0.7;
text-decoration: underline;
}
/* Claude-style scrollbar - thin, subtle, hover-only */
/* Claude-style scrollbar - thin, subtle, always visible but more prominent on hover */
#messages::-webkit-scrollbar {
width: 8px;
}
@ -620,21 +624,24 @@ footer {
background: transparent;
}
#messages::-webkit-scrollbar-thumb {
background: transparent;
background: rgba(128, 128, 128, 0.2);
border-radius: 4px;
transition: background 0.2s;
}
#messages:hover::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
background: rgba(128, 128, 128, 0.4);
}
#messages::-webkit-scrollbar-thumb:hover {
background: rgba(128, 128, 128, 0.5);
background: rgba(128, 128, 128, 0.6);
}
[data-theme="dark"] #messages::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
}
[data-theme="dark"] #messages:hover::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.25);
}
[data-theme="dark"] #messages::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.35);
}
/* Fallback for other elements */

View file

@ -1,8 +1,22 @@
// Singleton instance to prevent multiple initializations
let chatAppInstance = null;
function chatApp() {
// Return existing instance if already created
if (chatAppInstance) {
console.log("Returning existing chatApp instance");
return chatAppInstance;
}
console.log("Creating new chatApp instance");
// Core state variables (shared via closure)
let ws = null,
pendingContextChange = null,
o;
o,
isConnecting = false,
isInitialized = false,
authPromise = null;
((currentSessionId = null),
(currentUserId = null),
(currentBotId = "default_bot"),
@ -159,6 +173,13 @@ function chatApp() {
// Lifecycle / event handlers
// ----------------------------------------------------------------------
init() {
// Prevent multiple initializations
if (isInitialized) {
console.log("Already initialized, skipping...");
return;
}
isInitialized = true;
window.addEventListener("load", () => {
// Assign DOM elements after the document is ready
messagesDiv = document.getElementById("messages");
@ -196,6 +217,8 @@ function chatApp() {
// UI event listeners
document.addEventListener("click", (e) => {});
// Scroll detection
if (messagesDiv && scrollToBottomBtn) {
messagesDiv.addEventListener("scroll", () => {
const isAtBottom =
messagesDiv.scrollHeight - messagesDiv.scrollTop <=
@ -212,18 +235,17 @@ function chatApp() {
scrollToBottomBtn.addEventListener("click", () => {
this.scrollToBottom();
});
}
sendBtn.onclick = () => this.sendMessage();
messageInputEl.addEventListener("keypress", (e) => {
if (e.key === "Enter") this.sendMessage();
});
window.addEventListener("focus", () => {
if (!ws || ws.readyState !== WebSocket.OPEN) {
this.connectWebSocket();
}
});
// Start authentication flow
// Don't auto-reconnect on focus in browser to prevent multiple connections
// Tauri doesn't fire focus events the same way
// Initialize auth only once
this.initializeAuth();
});
},
@ -258,6 +280,25 @@ function chatApp() {
},
async initializeAuth() {
// Return existing promise if auth is in progress
if (authPromise) {
console.log("Auth already in progress, waiting...");
return authPromise;
}
// Already authenticated
if (
currentSessionId &&
currentUserId &&
ws &&
ws.readyState === WebSocket.OPEN
) {
console.log("Already authenticated and connected");
return;
}
// Create auth promise to prevent concurrent calls
authPromise = (async () => {
try {
this.updateConnectionStatus("connecting");
const p = window.location.pathname.split("/").filter((s) => s);
@ -268,12 +309,19 @@ function chatApp() {
const a = await r.json();
currentUserId = a.user_id;
currentSessionId = a.session_id;
console.log("Auth successful:", { currentUserId, currentSessionId });
this.connectWebSocket();
} catch (e) {
console.error("Failed to initialize auth:", e);
this.updateConnectionStatus("disconnected");
authPromise = null;
setTimeout(() => this.initializeAuth(), 3000);
} finally {
authPromise = null;
}
})();
return authPromise;
},
async loadSessions() {
@ -331,14 +379,37 @@ function chatApp() {
},
connectWebSocket() {
if (ws) {
// Prevent multiple simultaneous connection attempts
if (isConnecting) {
console.log("Already connecting to WebSocket, skipping...");
return;
}
if (
ws &&
(ws.readyState === WebSocket.OPEN ||
ws.readyState === WebSocket.CONNECTING)
) {
console.log("WebSocket already connected or connecting");
return;
}
if (ws && ws.readyState !== WebSocket.CLOSED) {
ws.close();
}
clearTimeout(reconnectTimeout);
isConnecting = true;
const u = this.getWebSocketUrl();
console.log("Connecting to WebSocket:", u);
ws = new WebSocket(u);
ws.onmessage = (e) => {
const r = JSON.parse(e.data);
// Filter out welcome/connection messages that aren't BotResponse
if (r.type === "connected" || !r.message_type) {
console.log("Ignoring non-message:", r);
return;
}
if (r.bot_id) {
currentBotId = r.bot_id;
}
@ -357,12 +428,14 @@ function chatApp() {
};
ws.onopen = () => {
console.log("Connected to WebSocket");
isConnecting = false;
this.updateConnectionStatus("connected");
reconnectAttempts = 0;
hasReceivedInitialMessage = false;
};
ws.onclose = (e) => {
console.log("WebSocket disconnected:", e.code, e.reason);
isConnecting = false;
this.updateConnectionStatus("disconnected");
if (isStreaming) {
this.showContinueButton();
@ -380,6 +453,7 @@ function chatApp() {
};
ws.onerror = (e) => {
console.error("WebSocket error:", e);
isConnecting = false;
this.updateConnectionStatus("disconnected");
};
},
@ -389,6 +463,12 @@ function chatApp() {
isContextChange = false;
return;
}
// Ignore messages without content
if (!r.content && r.is_complete !== true) {
return;
}
if (r.context_usage !== undefined) {
this.updateContextUsage(r.context_usage);
}
@ -401,7 +481,8 @@ function chatApp() {
isStreaming = false;
streamingMessageId = null;
currentStreamingContent = "";
} else {
} else if (r.content) {
// Only add message if there's actual content
this.addMessage("assistant", r.content, false);
}
} else {
@ -842,11 +923,67 @@ function chatApp() {
},
scrollToBottom() {
if (messagesDiv) {
messagesDiv.scrollTop = messagesDiv.scrollHeight;
isUserScrolling = false;
if (scrollToBottomBtn) {
scrollToBottomBtn.classList.remove("visible");
}
}
},
};
const returnValue = {
init: init,
current: current,
search: search,
selectedChat: selectedChat,
navItems: navItems,
chats: chats,
get filteredChats() {
return chats.filter((chat) =>
chat.name.toLowerCase().includes(search.toLowerCase()),
);
},
toggleSidebar: toggleSidebar,
toggleTheme: toggleTheme,
applyTheme: applyTheme,
updateContextUsage: updateContextUsage,
flashScreen: flashScreen,
updateConnectionStatus: updateConnectionStatus,
getWebSocketUrl: getWebSocketUrl,
initializeAuth: initializeAuth,
loadSessions: loadSessions,
createNewSession: createNewSession,
switchSession: switchSession,
connectWebSocket: connectWebSocket,
processMessageContent: processMessageContent,
handleEvent: handleEvent,
showThinkingIndicator: showThinkingIndicator,
hideThinkingIndicator: hideThinkingIndicator,
showWarning: showWarning,
showContinueButton: showContinueButton,
continueInterruptedResponse: continueInterruptedResponse,
addMessage: addMessage,
updateStreamingMessage: updateStreamingMessage,
finalizeStreamingMessage: finalizeStreamingMessage,
escapeHtml: escapeHtml,
clearSuggestions: clearSuggestions,
handleSuggestions: handleSuggestions,
setContext: setContext,
sendMessage: sendMessage,
toggleVoiceMode: toggleVoiceMode,
startVoiceSession: startVoiceSession,
stopVoiceSession: stopVoiceSession,
connectToVoiceRoom: connectToVoiceRoom,
startVoiceRecording: startVoiceRecording,
simulateVoiceTranscription: simulateVoiceTranscription,
scrollToBottom: scrollToBottom,
};
// Cache and return the singleton instance
chatAppInstance = returnValue;
return returnValue;
}
// Initialize the app

View file

@ -1,7 +1,6 @@
<!doctype html>
<html lang="en">
<head>
<head>
<meta charset="utf-8" />
<title>General Bots</title>
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
@ -11,33 +10,43 @@
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script defer src="js/alpine.js"></script>
</head>
</head>
<body>
<body>
<nav x-data="{ current: 'chat' }">
<div class="logo">⚡ General Bots</div>
<a href="#chat" @click.prevent="current = 'chat'; window.switchSection('chat')"
:class="{ active: current === 'chat' }">💬 Chat</a>
<a
href="#chat"
@click.prevent="current = 'chat'; window.switchSection('chat')"
:class="{ active: current === 'chat' }"
>💬 Chat</a
>
<a href="#drive" @click.prevent="current = 'drive'; window.switchSection('drive')"
:class="{ active: current === 'drive' }">📁 Drive</a>
<a href="#tasks" @click.prevent="current = 'tasks'; window.switchSection('tasks')"
:class="{ active: current === 'tasks' }">✓ Tasks</a>
<a href="#mail" @click.prevent="current = 'mail'; window.switchSection('mail')"
:class="{ active: current === 'mail' }">✉ Mail</a>
<a
href="#drive"
@click.prevent="current = 'drive'; window.switchSection('drive')"
:class="{ active: current === 'drive' }"
>📁 Drive</a
>
<a
href="#tasks"
@click.prevent="current = 'tasks'; window.switchSection('tasks')"
:class="{ active: current === 'tasks' }"
>✓ Tasks</a
>
<a
href="#mail"
@click.prevent="current = 'mail'; window.switchSection('mail')"
:class="{ active: current === 'mail' }"
>✉ Mail</a
>
</nav>
<div id="main-content">
<!-- Sections will be loaded dynamically -->
</div>
<!-- Load Module Scripts -->
<!-- Load Layout Script - module scripts are loaded dynamically -->
<script src="js/layout.js"></script>
<script src="chat/chat.js"></script>
<script src="drive/drive.js"></script>
<script src="tasks/tasks.js"></script>
<script src="mail/mail.js"></script>
</body>
</body>
</html>