From a64d4bc6ec46aaa38c2134a7dd59b5c095c4b7c7 Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:55:53 +0000 Subject: [PATCH 1/7] feat: polish the native main thread UI and surface backend outages Main thread view - Pin the input bar: the chat is the only scrolling region, and it follows the latest message. - Drop the inline session list and thread replies from the native view; child sessions stay in the side panel. - Compact header (state, model, pending) with a details toggle. - Show a rejected send (409) inline, keep the text, and disable input while the thread is working or rotating. - Label child reports, hide the pre-cut protocol turn, and render message text as safe markdown (raw HTML shown as text). - Backend: /main-thread and conversation polling no longer queue behind a rotation. Connection handling - Add a connection store (/health probe plus request failures) and a banner when the backend is unreachable; disable input and mark the header stale. - Retry the initial load and reload sessions, inbox and notifications when the backend returns. - Show a real error in the session panel instead of "No sessions yet". - Keep a message that the backend accepted if the reply poll later fails, so it isn't sent twice. Dev - Proxy /api through Vite (VITE_API_URL=/api, MAINLOOP_API_PROXY) so a remote dev box needs one forwarded port. --- backend/src/mainloop/api.py | 7 +- .../src/mainloop/runtime/native_sessions.py | 8 + frontend/src/lib/api.ts | 105 ++++++++---- frontend/src/lib/components/Chat.svelte | 155 +++++++++++++----- .../lib/components/ConnectionBanner.svelte | 26 +++ .../lib/components/ConversationView.svelte | 151 +++++++---------- .../lib/components/MainThreadHeader.svelte | 78 +++++++++ .../src/lib/components/MessageBubble.svelte | 36 ++-- .../lib/components/NativeIdentityStrip.svelte | 4 +- .../src/lib/components/SessionList.svelte | 15 ++ frontend/src/lib/config.ts | 2 + frontend/src/lib/markdown.ts | 28 ++++ frontend/src/lib/messages.ts | 46 ++++++ frontend/src/lib/sse.ts | 2 +- frontend/src/lib/stores/connection.ts | 101 ++++++++++++ frontend/src/routes/+layout.svelte | 22 ++- frontend/vite.config.ts | 16 +- 17 files changed, 616 insertions(+), 186 deletions(-) create mode 100644 frontend/src/lib/components/ConnectionBanner.svelte create mode 100644 frontend/src/lib/components/MainThreadHeader.svelte create mode 100644 frontend/src/lib/config.ts create mode 100644 frontend/src/lib/markdown.ts create mode 100644 frontend/src/lib/messages.ts create mode 100644 frontend/src/lib/stores/connection.ts diff --git a/backend/src/mainloop/api.py b/backend/src/mainloop/api.py index 206989a..97c4ef8 100644 --- a/backend/src/mainloop/api.py +++ b/backend/src/mainloop/api.py @@ -342,7 +342,10 @@ async def get_main_thread_info(user_id: str = Header(alias="X-User-ID", default= from mainloop.runtime import delegation, native_sessions binding = await delegation.ensure_main_session(user_id) - await native_sessions.sync(binding["session_id"]) + # A rotation holds the session lock for the cut; do not queue behind it, so the UI can + # show "rotating" while it happens (the reconcile loop mirrors journal evidence anyway). + if not native_sessions.is_rotating(binding["session_id"]): + await native_sessions.sync(binding["session_id"]) session = await db.get_session(binding["session_id"]) topics = await delegation._topic_lines(user_id) return MainThreadInfo( @@ -428,7 +431,7 @@ async def get_conversation(conversation_id: str): WHERE b.role='main' AND s.conversation_id=$1""", conversation_id, ) - if main_sid: + if main_sid and not native_sessions.is_rotating(main_sid): await native_sessions.sync( main_sid ) # mirror new native-journal evidence first diff --git a/backend/src/mainloop/runtime/native_sessions.py b/backend/src/mainloop/runtime/native_sessions.py index d8577f6..18dc27e 100644 --- a/backend/src/mainloop/runtime/native_sessions.py +++ b/backend/src/mainloop/runtime/native_sessions.py @@ -54,6 +54,14 @@ ) +def is_rotating(session_id: str) -> bool: + return session_id in _rotating + + +def is_rotating(session_id: str) -> bool: + return session_id in _rotating + + def workspace_for(binding: dict) -> HerdrWorkspace: """One Herdr workspace pod per binding: ``main-0`` for the main thread, else ``workspace-0``.""" pod = binding.get("pod") or settings.workspace_pod diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1ab5130..2824aa2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -2,7 +2,31 @@ * API client for backend communication */ -const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'; +import { API_URL } from '$lib/config'; +import { connection } from '$lib/stores/connection'; + +/** A send the backend did not accept. `status` is 0 when it never got an HTTP response. */ +export class SendError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message); + } +} + +/** fetch that tells the connection store when the backend can't be reached at all. */ +async function apiFetch(input: string, init?: RequestInit): Promise { + try { + return await fetch(input, init); + } catch (error) { + // No HTTP response (refused, DNS, offline). Aborts are the caller's own doing. + if (!(error instanceof DOMException && error.name === 'AbortError')) { + connection.reportFailure(); + } + throw error; + } +} export interface Message { id: string; @@ -244,7 +268,7 @@ export interface SessionNotification { export const api = { async listConversations(): Promise<{ conversations: Conversation[]; total: number }> { - const response = await fetch(`${API_URL}/conversations`); + const response = await apiFetch(`${API_URL}/conversations`); if (!response.ok) throw new Error('Failed to list conversations'); return response.json(); }, @@ -252,26 +276,41 @@ export const api = { async getConversation( conversationId: string ): Promise<{ conversation: Conversation; messages: Message[] }> { - const response = await fetch(`${API_URL}/conversations/${conversationId}`); + const response = await apiFetch(`${API_URL}/conversations/${conversationId}`); if (!response.ok) throw new Error('Failed to get conversation'); return response.json(); }, async sendMessage(request: ChatRequest): Promise { - const response = await fetch(`${API_URL}/chat`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(request) - }); - if (!response.ok) throw new Error('Failed to send message'); + let response: Response; + try { + response = await apiFetch(`${API_URL}/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(request) + }); + } catch { + throw new SendError("Can't reach the Mainloop backend.", 0); + } + if (!response.ok) { + // The native main thread answers 409 with a reason (rotating, or a turn still in flight). + let detail = 'Failed to send message'; + try { + const body = await response.json(); + if (typeof body?.detail === 'string') detail = body.detail; + } catch { + // keep the generic message + } + throw new SendError(detail, response.status); + } return response.json(); }, // Inbox/Queue endpoints async getUnreadCount(): Promise { - const response = await fetch(`${API_URL}/queue/unread/count`); + const response = await apiFetch(`${API_URL}/queue/unread/count`); if (!response.ok) throw new Error('Failed to get unread count'); const data = await response.json(); return data.count; @@ -288,33 +327,33 @@ export const api = { if (options?.taskId) params.set('task_id', options.taskId); const url = params.toString() ? `${API_URL}/queue?${params}` : `${API_URL}/queue`; - const response = await fetch(url); + const response = await apiFetch(url); if (!response.ok) throw new Error('Failed to list queue items'); return response.json(); }, async getQueueItem(itemId: string): Promise { - const response = await fetch(`${API_URL}/queue/${itemId}`); + const response = await apiFetch(`${API_URL}/queue/${itemId}`); if (!response.ok) throw new Error('Failed to get queue item'); return response.json(); }, async markQueueItemRead(itemId: string): Promise { - const response = await fetch(`${API_URL}/queue/${itemId}/read`, { + const response = await apiFetch(`${API_URL}/queue/${itemId}/read`, { method: 'POST' }); if (!response.ok) throw new Error('Failed to mark queue item read'); }, async markAllQueueItemsRead(): Promise { - const response = await fetch(`${API_URL}/queue/read-all`, { + const response = await apiFetch(`${API_URL}/queue/read-all`, { method: 'POST' }); if (!response.ok) throw new Error('Failed to mark all read'); }, async respondToQueueItem(itemId: string, responseText: string): Promise { - const response = await fetch(`${API_URL}/queue/${itemId}/respond`, { + const response = await apiFetch(`${API_URL}/queue/${itemId}/respond`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -327,25 +366,25 @@ export const api = { // Project endpoints async listProjects(limit?: number): Promise { const params = limit ? `?limit=${limit}` : ''; - const response = await fetch(`${API_URL}/projects${params}`); + const response = await apiFetch(`${API_URL}/projects${params}`); if (!response.ok) throw new Error('Failed to list projects'); return response.json(); }, async getProject(projectId: string): Promise { - const response = await fetch(`${API_URL}/projects/${projectId}`); + const response = await apiFetch(`${API_URL}/projects/${projectId}`); if (!response.ok) throw new Error('Failed to get project'); return response.json(); }, async getProjectDetail(projectId: string): Promise { - const response = await fetch(`${API_URL}/projects/${projectId}/detail`); + const response = await apiFetch(`${API_URL}/projects/${projectId}/detail`); if (!response.ok) throw new Error('Failed to get project detail'); return response.json(); }, async refreshProject(projectId: string): Promise { - const response = await fetch(`${API_URL}/projects/${projectId}/refresh`, { + const response = await apiFetch(`${API_URL}/projects/${projectId}/refresh`, { method: 'POST' }); if (!response.ok) throw new Error('Failed to refresh project'); @@ -363,13 +402,13 @@ export const api = { const params = new URLSearchParams(); if (options?.status) params.set('status', options.status); const url = params.toString() ? `${API_URL}/sessions?${params}` : `${API_URL}/sessions`; - const response = await fetch(url); + const response = await apiFetch(url); if (!response.ok) throw new Error('Failed to list sessions'); return response.json(); }, async createSession(request: SessionCreate): Promise { - const response = await fetch(`${API_URL}/sessions`, { + const response = await apiFetch(`${API_URL}/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -381,32 +420,32 @@ export const api = { }, async getMainThread(): Promise { - const response = await fetch(`${API_URL}/main-thread`); + const response = await apiFetch(`${API_URL}/main-thread`); if (!response.ok) throw new Error('Failed to get main thread'); return response.json(); }, async rotateMainThread(): Promise> { - const response = await fetch(`${API_URL}/main-thread/rotate`, { method: 'POST' }); + const response = await apiFetch(`${API_URL}/main-thread/rotate`, { method: 'POST' }); if (!response.ok) throw new Error('Failed to rotate main thread'); return response.json(); }, async listTopics(): Promise { - const response = await fetch(`${API_URL}/topics`); + const response = await apiFetch(`${API_URL}/topics`); if (!response.ok) throw new Error('Failed to list topics'); return response.json(); }, async getSessionNative(sessionId: string): Promise { - const response = await fetch(`${API_URL}/sessions/${sessionId}/native`); + const response = await apiFetch(`${API_URL}/sessions/${sessionId}/native`); if (response.status === 404) return null; if (!response.ok) throw new Error('Failed to get native session info'); return response.json(); }, async getSession(sessionId: string): Promise { - const response = await fetch(`${API_URL}/sessions/${sessionId}`); + const response = await apiFetch(`${API_URL}/sessions/${sessionId}`); if (!response.ok) throw new Error('Failed to get session'); return response.json(); }, @@ -414,13 +453,13 @@ export const api = { async getSessionConversation( sessionId: string ): Promise<{ session: Session; messages: Message[] }> { - const response = await fetch(`${API_URL}/sessions/${sessionId}/conversation`); + const response = await apiFetch(`${API_URL}/sessions/${sessionId}/conversation`); if (!response.ok) throw new Error('Failed to get session conversation'); return response.json(); }, async sendSessionMessage(sessionId: string, message: string): Promise<{ message_id: string }> { - const response = await fetch(`${API_URL}/sessions/${sessionId}/message`, { + const response = await apiFetch(`${API_URL}/sessions/${sessionId}/message`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -432,7 +471,7 @@ export const api = { }, async cancelSession(sessionId: string): Promise { - const response = await fetch(`${API_URL}/sessions/${sessionId}/cancel`, { + const response = await apiFetch(`${API_URL}/sessions/${sessionId}/cancel`, { method: 'POST' }); if (!response.ok) throw new Error('Failed to cancel session'); @@ -442,13 +481,13 @@ export const api = { async listNotifications(unreadOnly: boolean = true): Promise { const params = new URLSearchParams(); params.set('unread_only', unreadOnly.toString()); - const response = await fetch(`${API_URL}/notifications?${params}`); + const response = await apiFetch(`${API_URL}/notifications?${params}`); if (!response.ok) throw new Error('Failed to list notifications'); return response.json(); }, async dismissNotification(notificationId: string): Promise { - const response = await fetch(`${API_URL}/notifications/${notificationId}/dismiss`, { + const response = await apiFetch(`${API_URL}/notifications/${notificationId}/dismiss`, { method: 'POST' }); if (!response.ok) throw new Error('Failed to dismiss notification'); diff --git a/frontend/src/lib/components/Chat.svelte b/frontend/src/lib/components/Chat.svelte index a5e6a67..222d5b4 100644 --- a/frontend/src/lib/components/Chat.svelte +++ b/frontend/src/lib/components/Chat.svelte @@ -5,15 +5,62 @@ import { sessions } from '$lib/stores/sessions'; import { navigationContext, currentSession, isMainContext } from '$lib/stores/navigationContext'; import { allSessionMessages } from '$lib/stores/sessionMessages'; - import { api, type MainThreadInfo } from '$lib/api'; + import { api, SendError, type MainThreadInfo } from '$lib/api'; + import { draftMessage } from '$lib/stores/draftMessage'; + import { connection } from '$lib/stores/connection'; + import { visibleMessages } from '$lib/messages'; import ConversationView from './ConversationView.svelte'; - import NativeIdentityStrip from './NativeIdentityStrip.svelte'; + import MainThreadHeader from './MainThreadHeader.svelte'; // Native main thread (MAIN_THREAD_MODE=native): a Claude session under Herdr whose window // Mainloop rotates. The reply is mirrored from the native journal, so we poll for it. let mainThread = $state(null); + let sendError = $state(null); - let { messages, isLoading } = $derived($conversationStore); + let { messages: allMessages, isLoading } = $derived($conversationStore); + const native = $derived(mainThread?.mode === 'native'); + // Protocol traffic (the pre-cut turn) is not a conversation the user had. + const messages = $derived(native ? visibleMessages(allMessages) : allMessages); + // The main thread takes one message at a time; say so instead of letting a send fail. + const busy = $derived( + native && !!(mainThread?.native?.turn_in_flight || mainThread?.native?.rotating) + ); + const offline = $derived($connection.status === 'offline'); + const placeholder = $derived( + offline + ? 'Backend unreachable…' + : $currentSession + ? `Reply to ${$currentSession.title}...` + : mainThread?.native?.rotating + ? 'Resetting the context window…' + : busy + ? 'Working…' + : 'Enter command...' + ); + + // Keep the main thread live without a send: child reports and rotations arrive on their own. + $effect(() => { + if (!native) return; + let stopped = false; + const tick = async () => { + if (stopped || $conversationStore.isLoading) return; + try { + const info = await api.getMainThread(); + mainThread = info; + if (info.conversation_id) { + const { messages: fresh } = await api.getConversation(info.conversation_id); + if (!stopped) conversationStore.setMessages(fresh); + } + } catch (error) { + console.error('Main thread refresh failed:', error); + } + }; + const timer = setInterval(tick, 4000); + return () => { + stopped = true; + clearInterval(timer); + }; + }); // Start polling for all session messages $effect(() => { @@ -31,19 +78,28 @@ } }); - onMount(async () => { + // Whether the initial load finished. Until it has, an empty thread means "not loaded", not + // "nothing here", so the load is retried when the backend becomes reachable. + let loaded = $state(false); + let loadInFlight = false; + + async function loadInitial() { + if (loaded || loadInFlight) return; + loadInFlight = true; try { - mainThread = await api.getMainThread(); - if (mainThread.mode === 'native' && mainThread.conversation_id) { - const { conversation, messages } = await api.getConversation(mainThread.conversation_id); - conversationStore.setConversation(conversation, messages); + try { + mainThread = await api.getMainThread(); + if (mainThread.mode === 'native' && mainThread.conversation_id) { + const { conversation, messages } = await api.getConversation(mainThread.conversation_id); + conversationStore.setConversation(conversation, messages); + loaded = true; + return; + } + } catch (error) { + console.error('Failed to load main thread info:', error); return; } - } catch (error) { - console.error('Failed to load main thread info:', error); - } - // Load the most recent conversation on startup - try { + // Load the most recent conversation on startup const { conversations } = await api.listConversations(); if (conversations.length > 0) { // Load the most recent conversation (already sorted by updated_at desc) @@ -51,9 +107,19 @@ const { conversation, messages } = await api.getConversation(latest.id); conversationStore.setConversation(conversation, messages); } + loaded = true; } catch (error) { console.error('Failed to load conversation:', error); + } finally { + loadInFlight = false; } + } + + onMount(loadInitial); + + // Retry a failed initial load as soon as the backend is reachable again. + $effect(() => { + if ($connection.status === 'online') void loadInitial(); }); async function handleSendMessage(detail: { message: string }) { @@ -71,8 +137,10 @@ const currentConversationId = $conversationStore.currentConversation?.id; // Optimistic: Add user message immediately + const tempId = `temp-${Date.now()}`; + sendError = null; conversationStore.addMessage({ - id: `temp-${Date.now()}`, + id: tempId, conversation_id: currentConversationId || 'pending', role: 'user', content: userMessage, @@ -81,11 +149,15 @@ conversationStore.setLoading(true); + // Once the backend has accepted the message it is delivered; a later failure (e.g. while + // waiting for the reply) must not roll it back, or the user would send it twice. + let accepted = false; try { const response = await api.sendMessage({ message: userMessage, conversation_id: currentConversationId }); + accepted = true; // Update conversation ID if this was the first message if (!currentConversationId) { @@ -124,6 +196,16 @@ projects.fetchProjects(); } catch (error) { console.error('Failed to send message:', error); + // Delivered, but the follow-up failed: keep the message; the live refresh (and the + // connection banner) take it from here. + if (accepted) return; + // Not delivered: take the optimistic bubble back, keep the text, and say why. + conversationStore.setMessages($conversationStore.messages.filter((m) => m.id !== tempId)); + draftMessage.set(userMessage); + sendError = + error instanceof SendError && (error.status === 409 || error.status === 0) + ? `${error.message} Your message is back in the box.` + : 'Could not send the message. Your message is back in the box.'; } finally { conversationStore.setLoading(false); } @@ -166,29 +248,24 @@ } -{#if mainThread?.mode === 'native' && mainThread.session_id} - -
- topics: - {#each mainThread.topics as t (t.name)} - {t.name}{t.status_line ? ` (${t.status_line})` : ''} [{t.pending} pending] - {:else} - none yet - {/each} +
+ {#if native && mainThread} + + {/if} + + +
+ (sendError = null)} + emptyStateTitle={loaded ? '$ mainloop --help' : '$ connecting'} + emptyStateMessage={loaded ? 'Start a conversation to begin' : 'Waiting for the backend…'} + />
-{/if} - - - +
diff --git a/frontend/src/lib/components/ConnectionBanner.svelte b/frontend/src/lib/components/ConnectionBanner.svelte new file mode 100644 index 0000000..3f39cf8 --- /dev/null +++ b/frontend/src/lib/components/ConnectionBanner.svelte @@ -0,0 +1,26 @@ + + +{#if $connection.status === 'offline'} + +{/if} diff --git a/frontend/src/lib/components/ConversationView.svelte b/frontend/src/lib/components/ConversationView.svelte index fa66de1..3beb7b3 100644 --- a/frontend/src/lib/components/ConversationView.svelte +++ b/frontend/src/lib/components/ConversationView.svelte @@ -4,7 +4,6 @@ import { sessions } from '$lib/stores/sessions'; import { navigationContext, currentSession } from '$lib/stores/navigationContext'; import { allSessionMessagesFlat } from '$lib/stores/sessionMessages'; - import { marked } from 'marked'; import MessageBubble from './MessageBubble.svelte'; import InputBar from './InputBar.svelte'; import SessionBlock from './SessionBlock.svelte'; @@ -17,7 +16,10 @@ emptyStateTitle = '$ mainloop --help', emptyStateMessage = 'Start a conversation to begin', showInlineSessions = true, - context = 'main' + context = 'main', + error = null, + inputDisabled = false, + onDismissError }: { messages: Message[]; isLoading: boolean; @@ -27,6 +29,11 @@ emptyStateMessage?: string; showInlineSessions?: boolean; context?: string; + /** A send that was rejected; shown above the input, not lost in the console. */ + error?: string | null; + /** Disable sending without implying a running turn (e.g. the window is rotating). */ + inputDisabled?: boolean; + onDismissError?: () => void; } = $props(); // Map of anchor_message_id -> sessions for inline rendering @@ -47,13 +54,7 @@ // Sessions without anchors (show at bottom of conversation) const unanchoredSessions = $derived(() => { if (!showInlineSessions) return []; - return $sessions.sessions.filter(s => !s.anchor_message_id); - }); - - // Configure marked for terminal aesthetic - marked.setOptions({ - breaks: true, - gfm: true + return $sessions.sessions.filter((s) => !s.anchor_message_id); }); // Unified timeline: merge main messages with session messages (when focused) @@ -94,17 +95,25 @@ let messagesContainer: HTMLDivElement; let showScrollButton = $state(false); + // Follow new messages while the reader is at the bottom (true on first load); scrolling up + // releases it. Measuring after the DOM grew would always look "far from the bottom". + let stickToBottom = true; + + function distanceFromBottom() { + const { scrollTop, scrollHeight, clientHeight } = messagesContainer; + return scrollHeight - scrollTop - clientHeight; + } - // Check if scrolled to bottom function checkScrollPosition() { if (!messagesContainer) return; - const { scrollTop, scrollHeight, clientHeight } = messagesContainer; - const distanceFromBottom = scrollHeight - scrollTop - clientHeight; - showScrollButton = distanceFromBottom > 100; + const distance = distanceFromBottom(); + stickToBottom = distance < 150; + showScrollButton = distance > 100; } function scrollToBottom() { if (messagesContainer) { + stickToBottom = true; messagesContainer.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' @@ -112,20 +121,13 @@ } } - // Auto-scroll to bottom when messages change (only if already near bottom) $effect(() => { // Track these values to trigger effect messages; isLoading; $allSessionMessagesFlat; - // Check if user was already near bottom before updates - const wasNearBottom = messagesContainer - ? messagesContainer.scrollHeight - messagesContainer.scrollTop - messagesContainer.clientHeight < 150 - : true; - - // Only auto-scroll if user was already at/near bottom - if (wasNearBottom) { + if (stickToBottom) { tick().then(() => { if (messagesContainer) { messagesContainer.scrollTop = messagesContainer.scrollHeight; @@ -140,15 +142,15 @@ } -
- +
+
{#if messages.length === 0} -
+

{emptyStateTitle}

{emptyStateMessage}

_

@@ -169,7 +171,9 @@ {@const isActiveSession = $navigationContext.currentContext === item.session.id}
-
+
{preview}{#if isLong}...{/if}
- → + → {/if} {/each} {#if showInlineSessions && unanchoredSessions().length > 0} -
-
Active Sessions
+
+
Active Sessions
{#each unanchoredSessions() as session (session.id)} - + {$currentSession.title} processing...
{/if} @@ -228,13 +234,13 @@ {#if isLoading}
- + claude@{context}$
- processing + processing _
@@ -246,7 +252,7 @@ + {/if} +
+ {/if} +
- - diff --git a/frontend/src/lib/components/MainThreadHeader.svelte b/frontend/src/lib/components/MainThreadHeader.svelte new file mode 100644 index 0000000..777f617 --- /dev/null +++ b/frontend/src/lib/components/MainThreadHeader.svelte @@ -0,0 +1,78 @@ + + +
+
+ + {status} + · + {model} + {#if pending > 0} + · + {pending} pending + {/if} + +
+ + {#if open} +
+ {#if info.session_id} + + {/if} +
+ topics: + {#each info.topics as t (t.name)} + {t.name}{t.status_line ? ` (${t.status_line})` : ''} [{t.pending} pending] + {:else} + none yet + {/each} +
+
+ {/if} +
diff --git a/frontend/src/lib/components/MessageBubble.svelte b/frontend/src/lib/components/MessageBubble.svelte index b62f9e8..78008a0 100644 --- a/frontend/src/lib/components/MessageBubble.svelte +++ b/frontend/src/lib/components/MessageBubble.svelte @@ -1,35 +1,41 @@
- {isUser ? 'user' : 'claude'}@{context}$ + {#if report} + child · {report.title}{report.fallback ? ' (ended without a report)' : ''} + {:else} + {isUser ? 'user' : 'claude'}@{context}$ + {/if}
-
+
{@html htmlContent}
-
diff --git a/frontend/src/lib/components/NativeIdentityStrip.svelte b/frontend/src/lib/components/NativeIdentityStrip.svelte index 7d21ace..17fa54f 100644 --- a/frontend/src/lib/components/NativeIdentityStrip.svelte +++ b/frontend/src/lib/components/NativeIdentityStrip.svelte @@ -87,8 +87,8 @@ {/if} {#if info.deliveries.length}
- deliveries: - {#each info.deliveries as d (d.message_id)} + deliveries: {info.deliveries.length} + {#each info.deliveries.slice(-3) as d (d.message_id)} {d.state} {/each}
diff --git a/frontend/src/lib/components/SessionList.svelte b/frontend/src/lib/components/SessionList.svelte index 5049211..8abd2fc 100644 --- a/frontend/src/lib/components/SessionList.svelte +++ b/frontend/src/lib/components/SessionList.svelte @@ -45,6 +45,21 @@
Loading sessions...
+ {:else if $sessions.error && $sessions.sessions.length === 0} +
+

Couldn't load sessions

+

{$sessions.error}

+ +
{:else if $sessions.sessions.length === 0}

$ sessions --list

diff --git a/frontend/src/lib/config.ts b/frontend/src/lib/config.ts new file mode 100644 index 0000000..f4264b8 --- /dev/null +++ b/frontend/src/lib/config.ts @@ -0,0 +1,2 @@ +/** Base URL of the Mainloop backend. In dev, `/api` is proxied by Vite (see vite.config.ts). */ +export const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'; diff --git a/frontend/src/lib/markdown.ts b/frontend/src/lib/markdown.ts new file mode 100644 index 0000000..ab67ba3 --- /dev/null +++ b/frontend/src/lib/markdown.ts @@ -0,0 +1,28 @@ +/** + * Markdown rendering for chat messages. + * + * Message text can come from agents, so raw HTML in it is shown as text (never injected), and only + * http(s)/mailto links are made clickable. Angle brackets in prose (``) therefore stay visible. + */ +import { Marked, type Tokens } from 'marked'; + +const escapeHtml = (s: string) => + s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + +const md = new Marked({ breaks: true, gfm: true }); +md.use({ + renderer: { + html: ({ text }: Tokens.HTML | Tokens.Tag) => escapeHtml(text), + link({ href, title, tokens }: Tokens.Link) { + const text = this.parser.parseInline(tokens); + if (!/^(https?:|mailto:)/i.test(href)) return text; + const t = title ? ` title="${escapeHtml(title)}"` : ''; + return `${text}`; + }, + image: ({ text }: Tokens.Image) => escapeHtml(text) + } +}); + +export function renderMarkdown(text: string): string { + return md.parse(text) as string; +} diff --git a/frontend/src/lib/messages.ts b/frontend/src/lib/messages.ts new file mode 100644 index 0000000..5029e34 --- /dev/null +++ b/frontend/src/lib/messages.ts @@ -0,0 +1,46 @@ +/** Helpers for showing the native main thread's conversation. */ +import type { Message } from '$lib/api'; + +const PRE_CUT = '[mainloop:pre-cut]'; +const REPORT = /^\[report from child ([0-9a-f]{8}) '([^']*)'([^\]]*)\]\n?/; + +export interface ChildReport { + childId: string; + title: string; + fallback: boolean; + body: string; +} + +/** A child's report delivered to the main thread as a message, or null. */ +export function parseChildReport(content: string): ChildReport | null { + const m = REPORT.exec(content); + if (!m) return null; + return { + childId: m[1], + title: m[2], + fallback: m[3].includes('fallback'), + body: content.slice(m[0].length) + }; +} + +/** + * Hide protocol traffic: the pre-cut write-out prompt and the agent's reply to it. Both stay in + * Postgres; they just are not a conversation the user had. + */ +export function visibleMessages(messages: Message[]): Message[] { + const out: Message[] = []; + let skipReply = false; + for (const m of messages) { + if (m.role === 'user' && m.content.startsWith(PRE_CUT)) { + skipReply = true; + continue; + } + if (skipReply && m.role === 'assistant') { + skipReply = false; + continue; + } + skipReply = false; + out.push(m); + } + return out; +} diff --git a/frontend/src/lib/sse.ts b/frontend/src/lib/sse.ts index 570e96d..5cedac9 100644 --- a/frontend/src/lib/sse.ts +++ b/frontend/src/lib/sse.ts @@ -4,7 +4,7 @@ * Uses EventSource for automatic reconnection and native browser support. */ -const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'; +import { API_URL } from '$lib/config'; export type SSEEventType = | 'connected' diff --git a/frontend/src/lib/stores/connection.ts b/frontend/src/lib/stores/connection.ts new file mode 100644 index 0000000..bb61a18 --- /dev/null +++ b/frontend/src/lib/stores/connection.ts @@ -0,0 +1,101 @@ +/** + * Backend reachability, so a dead service reads as "down" instead of as empty or half-working UI. + * + * Two inputs: a periodic /health probe, and failures reported by the API client (a request that + * never got an HTTP response flips the state immediately rather than waiting for the next probe). + */ + +import { writable } from 'svelte/store'; +import { API_URL } from '$lib/config'; + +export type ConnectionStatus = 'checking' | 'online' | 'offline'; + +interface ConnectionState { + status: ConnectionStatus; + /** When the backend was first seen unreachable in the current outage (ISO string). */ + offlineSince: string | null; + /** Incremented each time the backend comes back, so views can reload what they missed. */ + recoveries: number; +} + +const ONLINE_PROBE_MS = 5000; +const OFFLINE_PROBE_MS = 2000; +const PROBE_TIMEOUT_MS = 4000; + +function createConnectionStore() { + const { subscribe, update } = writable({ + status: 'checking', + offlineSince: null, + recoveries: 0 + }); + + let timer: ReturnType | null = null; + let running = false; + let current: ConnectionStatus = 'checking'; + + function setStatus(next: ConnectionStatus) { + const previous = current; + current = next; + update((s) => ({ + status: next, + offlineSince: next === 'offline' ? (s.offlineSince ?? new Date().toISOString()) : null, + recoveries: next === 'online' && previous === 'offline' ? s.recoveries + 1 : s.recoveries + })); + } + + async function probe() { + try { + const response = await fetch(`${API_URL}/health`, { + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + cache: 'no-store' + }); + // /health never fails while the backend is up; a proxy (dev server, ingress) answers 5xx + // when nothing is behind it. + setStatus(response.ok ? 'online' : 'offline'); + } catch { + setStatus('offline'); + } + } + + function schedule() { + if (!running) return; + timer = setTimeout( + async () => { + await probe(); + schedule(); + }, + current === 'offline' ? OFFLINE_PROBE_MS : ONLINE_PROBE_MS + ); + } + + return { + subscribe, + + /** Begin probing; returns a stop function. */ + start(): () => void { + if (running) return () => {}; + running = true; + void probe().then(schedule); + const recheck = () => void probe(); + const goOffline = () => setStatus('offline'); + window.addEventListener('online', recheck); + window.addEventListener('offline', goOffline); + return () => { + running = false; + if (timer) clearTimeout(timer); + window.removeEventListener('online', recheck); + window.removeEventListener('offline', goOffline); + }; + }, + + /** + * The API client saw a request fail without any HTTP response. Only failures are reported; + * recovery is decided by the probe, since a proxy can answer HTTP while the backend is down. + */ + reportFailure() { + if (current !== 'offline') setStatus('offline'); + } + }; +} + +export const connection = createConnectionStore(); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 9e8d879..06caf93 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -8,6 +8,8 @@ import { themeStore } from '$lib/stores/theme'; import { mobileTab } from '$lib/stores/mobileTab'; import { isMobile } from '$lib/stores/viewport'; + import { connection } from '$lib/stores/connection'; + import ConnectionBanner from '$lib/components/ConnectionBanner.svelte'; import { navigationContext, isZoomed } from '$lib/stores/navigationContext'; import { connectSSE, disconnectSSE, getSSEClient } from '$lib/sse'; import TasksBadge from '$lib/components/TasksBadge.svelte'; @@ -68,8 +70,21 @@ } } + // The backend came back after an outage: reload what the failed requests left empty or stale. + // (SSE reconnects by itself; polling views catch up on their own timers.) + let seenRecoveries = 0; + $effect(() => { + const recoveries = $connection.recoveries; + if (recoveries === seenRecoveries) return; + seenRecoveries = recoveries; + sessions.fetchSessions(); + notifications.fetchNotifications(); + inbox.fetchItems(); + }); + onMount(() => { themeStore.initialize(); + const stopConnectionMonitor = connection.start(); // Connect SSE for real-time updates connectSSE(); @@ -106,6 +121,7 @@ inbox.fetchItems(); return () => { + stopConnectionMonitor(); inbox.stopListening(); unsubSessionUpdated(); unsubSessionNeedsInput(); @@ -126,13 +142,14 @@ {#if $isMobile} -
+

$ mainloop

+
{#if $navigationContext.zoomedSession} @@ -150,7 +167,7 @@
{:else} -
+

$ mainloop @@ -161,6 +178,7 @@

+
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 847bfa5..0bbf35b 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -15,6 +15,20 @@ function storeHmrPlugin(): Plugin { }; } +// Dev only: with VITE_API_URL=/api the browser talks to this server, which forwards to the +// backend. A remote dev box then needs one forwarded port (the UI) instead of two, and the +// backend's localhost-only CORS rule never comes into play. +const apiProxyTarget = process.env.MAINLOOP_API_PROXY || 'http://localhost:8000'; + export default defineConfig({ - plugins: [sveltekit(), tailwindcss(), storeHmrPlugin()] + plugins: [sveltekit(), tailwindcss(), storeHmrPlugin()], + server: { + proxy: { + '/api': { + target: apiProxyTarget, + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, '') + } + } + } }); From 5e5804a6dd7a76ff093f4ac47824d7425ea29806 Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:32:26 +0000 Subject: [PATCH 2/7] fix: switch sessions from the list, reach sessions on mobile, drop the Logs tab Session page - Reload when only [id] changes; the page loaded once on mount, so clicking another session updated the URL but not the view - Keep status live from the sessions store; show "can't reach the backend" (and retry on recovery) instead of "Session not found" - Fix the "WAITING ON_USER" label; share status labels with the list - Collapse the identity strip to one summary line with a details toggle - Remove the Logs tab and its dead code: LogViewer, createTaskLogClient and the stub GET /sessions/{id}/logs endpoint Mobile and layout - Add the Sessions tab to the mobile tab bar (docs/specs/layout.md already called for it) - Size the desktop inbox and projects panels to their content - Highlight the open session in the list Chat - Wrap long unbroken text and scroll wide tables inside bubbles - Show the date on messages that are not from today - Input: refocus after a send, ignore Enter during IME composition, grow for multi-line text - SessionChat: inline, self-clearing errors; restore the draft when a send fails; disable input while the backend is unreachable Other - Project page read `tasks`, which the API does not return, and crashed on load; list the project's sessions instead and reload on id change - Use theme colors on the error page - Update docs/specs/layout.md and sessions.md; update the two specs that asserted on the Logs tab Not run: the Playwright fast/mobile projects, because their global setup resets the target database. --- backend/src/mainloop/api.py | 36 --- docs/specs/layout.md | 6 +- docs/specs/sessions.md | 6 +- .../lib/components/ConversationView.svelte | 3 +- frontend/src/lib/components/InputBar.svelte | 35 ++- frontend/src/lib/components/LogViewer.svelte | 269 ------------------ .../lib/components/MainThreadHeader.svelte | 2 +- .../src/lib/components/MessageBubble.svelte | 12 +- .../src/lib/components/MobileTabBar.svelte | 28 ++ .../lib/components/NativeIdentityStrip.svelte | 49 +++- .../src/lib/components/SessionChat.svelte | 53 ++-- .../src/lib/components/SessionList.svelte | 2 + .../src/lib/components/SessionListItem.svelte | 22 +- frontend/src/lib/components/TasksPanel.svelte | 4 +- frontend/src/lib/sessionStatus.ts | 15 + frontend/src/lib/sse.ts | 7 - frontend/src/lib/stores/mobileTab.ts | 2 +- frontend/src/lib/time.ts | 14 + frontend/src/routes/+error.svelte | 7 +- frontend/src/routes/+layout.svelte | 9 +- .../src/routes/projects/[id]/+page.svelte | 61 ++-- .../src/routes/sessions/[id]/+page.svelte | 107 ++++--- .../sessions/04-session-expanded-view.spec.ts | 38 +-- .../06-session-fullscreen-page.spec.ts | 6 +- 24 files changed, 309 insertions(+), 484 deletions(-) delete mode 100644 frontend/src/lib/components/LogViewer.svelte create mode 100644 frontend/src/lib/sessionStatus.ts create mode 100644 frontend/src/lib/time.ts diff --git a/backend/src/mainloop/api.py b/backend/src/mainloop/api.py index 97c4ef8..cba779f 100644 --- a/backend/src/mainloop/api.py +++ b/backend/src/mainloop/api.py @@ -888,42 +888,6 @@ async def send_session_message( return {"status": "ok", "message_id": message.id} -class SessionLogsResponse(BaseModel): - """Response for session logs.""" - - logs: str - source: str - session_status: str - - -@app.get("/sessions/{session_id}/logs", response_model=SessionLogsResponse) -async def get_session_logs( - session_id: str, - tail: int = 100, - user_id: str = Header(alias="X-User-ID", default=None), -): - """Get execution logs for a session.""" - if not user_id: - user_id = get_user_id_from_cf_header() - - session = await db.get_session(session_id) - if not session: - raise HTTPException(status_code=404, detail="Session not found") - - if session.user_id != user_id: - raise HTTPException(status_code=403, detail="Not your session") - - # TODO: Get logs from K8s pod - logs = "" - source = "none" - - return SessionLogsResponse( - logs=logs, - source=source, - session_status=session.status.value, - ) - - @app.post("/sessions/{session_id}/cancel") async def cancel_session( session_id: str, diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 4052ead..31734c3 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -5,13 +5,13 @@ Mainloop is responsive across mobile and desktop viewports. ## Desktop - Chat takes main area -- Sessions sidebar always visible on the right +- Sessions sidebar always visible on the right; the inbox and projects below it size to their content - No tab bar ## Mobile -- Bottom tab bar with Chat and Sessions tabs +- Bottom tab bar with Chat, Sessions and Inbox tabs (the Sessions tab includes the "+ agent" link) - Chat tab active by default on load - Tab bar hidden on desktop viewports - Touch targets sized appropriately for mobile interaction -- Tabs switch between Chat and Sessions views +- Tabs switch between the Chat, Sessions and Inbox views diff --git a/docs/specs/sessions.md b/docs/specs/sessions.md index f4d8c45..572fcca 100644 --- a/docs/specs/sessions.md +++ b/docs/specs/sessions.md @@ -35,12 +35,16 @@ Clicking a session navigates to `/sessions/{id}`: - Shows title as h1 heading - Shows description if present -- Has Chat and Logs tabs (Chat tab active by default) +- Shows the session's chat directly (there is no Logs tab) +- Shows a one-line identity summary (agent, model, live or idle, topic) that expands to the full identity strip +- Follows the URL: opening another session from the list switches to it +- The session open in the main pane is highlighted in the list - Active sessions show Cancel button - Completed sessions show Summary section - Failed sessions show Error section - Back button returns to home - Non-existent session ID shows "Session not found" with link to home +- When the backend is unreachable the page says so and retries when it returns, instead of "Session not found" ## Notifications diff --git a/frontend/src/lib/components/ConversationView.svelte b/frontend/src/lib/components/ConversationView.svelte index 3beb7b3..05833df 100644 --- a/frontend/src/lib/components/ConversationView.svelte +++ b/frontend/src/lib/components/ConversationView.svelte @@ -4,6 +4,7 @@ import { sessions } from '$lib/stores/sessions'; import { navigationContext, currentSession } from '$lib/stores/navigationContext'; import { allSessionMessagesFlat } from '$lib/stores/sessionMessages'; + import { messageTime } from '$lib/time'; import MessageBubble from './MessageBubble.svelte'; import InputBar from './InputBar.svelte'; import SessionBlock from './SessionBlock.svelte'; @@ -189,7 +190,7 @@ ·
diff --git a/frontend/src/lib/components/InputBar.svelte b/frontend/src/lib/components/InputBar.svelte index 799d3e6..fbabac3 100644 --- a/frontend/src/lib/components/InputBar.svelte +++ b/frontend/src/lib/components/InputBar.svelte @@ -17,15 +17,43 @@ // Derive border color from current session context const borderColor = $derived(sessionColor ?? $currentSession?.color ?? null); + const MAX_HEIGHT_PX = 160; + + let textarea = $state(); + // The box is disabled while a reply is pending, which drops focus; take it back afterwards. + let refocusWhenEnabled = false; + + // Grow with the text (Shift+Enter adds lines) up to a cap, and shrink back after a send. + $effect(() => { + if (!textarea) return; + // Empty: one row, whatever the placeholder's length (it would otherwise size the box). + if (!$draftMessage) { + textarea.style.height = ''; + return; + } + textarea.style.height = 'auto'; + textarea.style.height = `${Math.min(textarea.scrollHeight, MAX_HEIGHT_PX)}px`; + }); + + $effect(() => { + if (disabled || !refocusWhenEnabled || !textarea) return; + refocusWhenEnabled = false; + // Only when focus was lost, not when the user has moved on to something else. + if (document.activeElement === document.body) textarea.focus(); + }); + function handleSubmit(event: SubmitEvent) { event.preventDefault(); if ($draftMessage.trim() && !disabled && onsend) { onsend({ message: $draftMessage.trim() }); draftMessage.set(''); + refocusWhenEnabled = true; } } function handleKeydown(event: KeyboardEvent) { + // Enter confirms an IME candidate (CJK, etc.); it must not send the half-composed message. + if (event.isComposing) return; if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); handleSubmit(event as any); @@ -37,18 +65,19 @@
- $ + $ - {/if} -
- {/if} -
diff --git a/frontend/src/lib/components/MainThreadHeader.svelte b/frontend/src/lib/components/MainThreadHeader.svelte index 777f617..046343c 100644 --- a/frontend/src/lib/components/MainThreadHeader.svelte +++ b/frontend/src/lib/components/MainThreadHeader.svelte @@ -61,7 +61,7 @@ {#if open}
{#if info.session_id} - + {/if}
topics: diff --git a/frontend/src/lib/components/MessageBubble.svelte b/frontend/src/lib/components/MessageBubble.svelte index 78008a0..a80ffdf 100644 --- a/frontend/src/lib/components/MessageBubble.svelte +++ b/frontend/src/lib/components/MessageBubble.svelte @@ -2,6 +2,7 @@ import type { Message } from '$lib/api'; import { renderMarkdown } from '$lib/markdown'; import { parseChildReport } from '$lib/messages'; + import { messageTime } from '$lib/time'; let { message, context = 'main' }: { message: Message; context?: string } = $props(); let isUser = $derived(message.role === 'user'); @@ -36,7 +37,7 @@ {@html htmlContent}
@@ -44,6 +45,10 @@