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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.5.5] - 2026-06-18

### Fixed

- 🔌 **Socket events no longer missed on slow connections.** Components that listen for real-time updates (sidebar chat list, chat panel, notifications) previously relied on polling to check if the socket was ready. If the socket connected slowly or reconnected, events could be silently dropped. Listeners are now registered centrally and automatically re-attached whenever the connection comes back.
- 🔄 **WebSocket connection is more resilient.** The socket now prefers a direct WebSocket connection and falls back to polling only when needed, reducing latency and improving reliability on unstable networks.

### Changed

- 📂 **Git status refreshes are debounced.** Rapid filesystem changes (like saving multiple files at once) no longer trigger a flood of git status checks. Updates are batched with a short delay so the UI stays responsive.

## [0.5.4] - 2026-06-18

### Added
Expand Down
93 changes: 51 additions & 42 deletions cptr/frontend/src/lib/components/Sidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
sidebarWidth,
appVersion,
showChangelog,
showSearch,
showSearch
} from '$lib/stores';
import Sortable from 'sortablejs';
import Icon from './Icon.svelte';
Expand Down Expand Up @@ -41,6 +41,7 @@
let menuButtonEl: HTMLButtonElement | undefined = $state();
let wsListEl: HTMLDivElement | undefined = $state();
let sortable: Sortable | null = null;
let unbindSocketListener: (() => void) | null = null;

// ── Per-workspace chat history ──────────────────────────────
let expandedWorkspaces = $state<Set<string>>(new Set());
Expand Down Expand Up @@ -119,7 +120,13 @@
// Socket listener for chat events — invalidate cache when chats update
const seenChatIds = new Set<string>();

function handleChatEvent(data: { chat_id: string; done?: boolean; title?: string; delta?: string; workspace?: string }) {
function handleChatEvent(data: {
chat_id: string;
done?: boolean;
title?: string;
delta?: string;
workspace?: string;
}) {
// Re-fetch on done, title update, or first event of a new chat
const isNew = !seenChatIds.has(data.chat_id);
seenChatIds.add(data.chat_id);
Expand Down Expand Up @@ -237,23 +244,13 @@
}

// Bind socket listener for chat cache invalidation
const tryBind = () => {
const socket = socketStore.getSocket();
if (!socket) {
setTimeout(tryBind, 200);
return;
}
socket.on('events:chat', handleChatEvent);
};
tryBind();
unbindSocketListener = socketStore.on('events:chat', handleChatEvent);
});

onDestroy(() => {
sortable?.destroy();
const socket = socketStore.getSocket();
if (socket) {
socket.off('events:chat', handleChatEvent);
}
unbindSocketListener?.();
unbindSocketListener = null;
});
</script>

Expand Down Expand Up @@ -307,26 +304,31 @@
<span class="flex-1 text-left overflow-hidden text-ellipsis whitespace-nowrap"
>{$t('search.search')}</span
>
<KeyPill text={searchShortcut} class="ml-auto shrink-0 opacity-0 group-hover:opacity-100 transition-opacity duration-100" />
<KeyPill
text={searchShortcut}
class="ml-auto shrink-0 opacity-0 group-hover:opacity-100 transition-opacity duration-100"
/>
</button>
</div>

<!-- Automations (only when chat/LLM backend is available) -->
{#if $chatEnabled}
<div class="px-1.5 shrink-0">
<a
href="/automations"
class="flex items-center gap-1.5 w-full h-7 px-2 rounded-lg text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors duration-100 no-underline"
onclick={(e) => {
e.preventDefault();
goto('/automations');
if (typeof window !== 'undefined' && window.innerWidth < 768) sidebarOpen.set(false);
}}
>
<Icon name="clock" size={14} />
<span class="flex-1 text-left overflow-hidden text-ellipsis whitespace-nowrap">{$t('automations.title')}</span>
</a>
</div>
<div class="px-1.5 shrink-0">
<a
href="/automations"
class="flex items-center gap-1.5 w-full h-7 px-2 rounded-lg text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors duration-100 no-underline"
onclick={(e) => {
e.preventDefault();
goto('/automations');
if (typeof window !== 'undefined' && window.innerWidth < 768) sidebarOpen.set(false);
}}
>
<Icon name="clock" size={14} />
<span class="flex-1 text-left overflow-hidden text-ellipsis whitespace-nowrap"
>{$t('automations.title')}</span
>
</a>
</div>
{/if}

<!-- Workspace section header -->
Expand All @@ -349,10 +351,12 @@
{@const chats = wsChatsCache.get(ws.path)}
{@const isLoading = wsChatsLoading.has(ws.path)}
<div class="ws-item">
<div class="group flex items-center gap-1 w-full h-7 px-2 rounded-lg text-xs font-medium transition-colors duration-100
<div
class="group flex items-center gap-1 w-full h-7 px-2 rounded-lg text-xs font-medium transition-colors duration-100
{ws.path === currentPath
? 'bg-gray-200/50 text-gray-900 dark:bg-white/8 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'}">
? 'bg-gray-200/50 text-gray-900 dark:bg-white/8 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'}"
>
<a
href="/?workspace={encodeURIComponent(ws.path)}"
class="flex items-center gap-1 flex-1 min-w-0 no-underline text-inherit"
Expand All @@ -372,7 +376,10 @@
aria-label={isExpanded ? $t('sidebar.collapse') : $t('sidebar.addWorkspace')}
>
<span class="ws-icon-folder"><Icon name="folder" size={14} /></span>
<span class="ws-icon-chevron" style="transform: rotate({isExpanded ? '90deg' : '0deg'})">
<span
class="ws-icon-chevron"
style="transform: rotate({isExpanded ? '90deg' : '0deg'})"
>
<Icon name="chevron-right" size={11} />
</span>
</span>
Expand Down Expand Up @@ -422,10 +429,7 @@
onclick={() => handleSidebarChatClick(chat.id, ws.path, chat.title)}
/>
{/each}
<button
class="ws-chat-show-more"
onclick={() => handleShowMoreChats(ws.path)}
>
<button class="ws-chat-show-more" onclick={() => handleShowMoreChats(ws.path)}>
{$t('sidebar.showMore')}
</button>
{:else if chats}
Expand All @@ -443,8 +447,6 @@
{/if}
</div>



<!-- Settings and profile footer pinned to the bottom -->
<div class="relative px-1 pb-0.5 shrink-0">
<button
Expand Down Expand Up @@ -495,7 +497,12 @@
]
: []),
...($session ? [{ divider: true, label: '', onclick: () => {} }] : []),
{ label: $t('sidebar.settings'), icon: 'settings', shortcut: formatChord($keybindings.openSettings), onclick: openSettings },
{
label: $t('sidebar.settings'),
icon: 'settings',
shortcut: formatChord($keybindings.openSettings),
onclick: openSettings
},
{ divider: true, label: '', onclick: () => {} },
{ label: $t('sidebar.logOut'), icon: 'log-out', onclick: logout }
]}
Expand Down Expand Up @@ -617,7 +624,9 @@
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.1s, transform 0.1s;
transition:
opacity 0.1s,
transform 0.1s;
color: #9ca3af;
}

Expand Down
22 changes: 8 additions & 14 deletions cptr/frontend/src/lib/components/chat/ChatPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
const ttsPreparedAudio = new Map<string, PreparedTtsAudio>();
const TTS_AUDIO_CACHE_LIMIT_BYTES = 20 * 1024 * 1024;
const TTS_MAX_PREFETCH = 2;
let unbindSocketListeners: (() => void) | null = null;

onMount(() => {
if (initialChatId || typeof sessionStorage === 'undefined') return;
Expand Down Expand Up @@ -500,25 +501,18 @@
loadPreviousChats();
}

const tryBind = () => {
const socket = socketStore.getSocket();
if (!socket) {
setTimeout(tryBind, 100);
return;
}
socket.on('events:chat', handleSocketEvent);
socket.on('connect', handleReconnect);
const offChat = socketStore.on('events:chat', handleSocketEvent);
const offConnect = socketStore.on('connect', handleReconnect);
unbindSocketListeners = () => {
offChat();
offConnect();
};
tryBind();
});

onDestroy(() => {
stopTtsPlayback();
const socket = socketStore.getSocket();
if (socket) {
socket.off('events:chat', handleSocketEvent);
socket.off('connect', handleReconnect);
}
unbindSocketListeners?.();
unbindSocketListeners = null;
if (landingRefreshTimer) clearTimeout(landingRefreshTimer);
// Don't clear streamingChatTabs here -- the global listener in
// chat.ts handles cleanup when the "done" event arrives, so the
Expand Down
Loading
Loading