feat(v2): add sidecar crash detection, restart UI, and auto-scroll lock

Phase 3 polish: dispatcher listens for sidecar-exited events and marks
running sessions as error. AgentPane shows "Restart Sidecar" button on
error. Auto-scroll disables when user scrolls >50px from bottom with
"Scroll to bottom" button. Added agent_restart Tauri command.
This commit is contained in:
Hibryda 2026-03-06 12:19:35 +01:00
parent da6d7272ee
commit 5ca035d438
4 changed files with 104 additions and 11 deletions

View file

@ -159,6 +159,12 @@ impl SidecarManager {
self.send_message(&msg) self.send_message(&msg)
} }
pub fn restart(&self, app: &AppHandle) -> Result<(), String> {
log::info!("Restarting sidecar");
let _ = self.shutdown();
self.start(app)
}
pub fn shutdown(&self) -> Result<(), String> { pub fn shutdown(&self) -> Result<(), String> {
let mut child_lock = self.child.lock().unwrap(); let mut child_lock = self.child.lock().unwrap();
if let Some(ref mut child) = *child_lock { if let Some(ref mut child) = *child_lock {

View file

@ -25,6 +25,10 @@ export async function isAgentReady(): Promise<boolean> {
return invoke<boolean>('agent_ready'); return invoke<boolean>('agent_ready');
} }
export async function restartAgent(): Promise<void> {
return invoke('agent_restart');
}
export interface SidecarMessage { export interface SidecarMessage {
type: string; type: string;
sessionId?: string; sessionId?: string;

View file

@ -1,7 +1,7 @@
// Agent Dispatcher — connects sidecar bridge events to agent store // Agent Dispatcher — connects sidecar bridge events to agent store
// Single listener that routes sidecar messages to the correct agent session // Single listener that routes sidecar messages to the correct agent session
import { onSidecarMessage, type SidecarMessage } from './adapters/agent-bridge'; import { onSidecarMessage, onSidecarExited, type SidecarMessage } from './adapters/agent-bridge';
import { adaptSDKMessage } from './adapters/sdk-messages'; import { adaptSDKMessage } from './adapters/sdk-messages';
import type { InitContent, CostContent } from './adapters/sdk-messages'; import type { InitContent, CostContent } from './adapters/sdk-messages';
import { import {
@ -10,14 +10,29 @@ import {
setAgentModel, setAgentModel,
appendAgentMessages, appendAgentMessages,
updateAgentCost, updateAgentCost,
getAgentSessions,
} from './stores/agents.svelte'; } from './stores/agents.svelte';
let unlistenFn: (() => void) | null = null; let unlistenMsg: (() => void) | null = null;
let unlistenExit: (() => void) | null = null;
// Sidecar liveness — checked by UI components
let sidecarAlive = true;
export function isSidecarAlive(): boolean {
return sidecarAlive;
}
export function setSidecarAlive(alive: boolean): void {
sidecarAlive = alive;
}
export async function startAgentDispatcher(): Promise<void> { export async function startAgentDispatcher(): Promise<void> {
if (unlistenFn) return; if (unlistenMsg) return;
sidecarAlive = true;
unlistenMsg = await onSidecarMessage((msg: SidecarMessage) => {
sidecarAlive = true;
unlistenFn = await onSidecarMessage((msg: SidecarMessage) => {
const sessionId = msg.sessionId; const sessionId = msg.sessionId;
if (!sessionId) return; if (!sessionId) return;
@ -39,10 +54,19 @@ export async function startAgentDispatcher(): Promise<void> {
break; break;
case 'agent_log': case 'agent_log':
// Debug logging — could route to a log panel later
break; break;
} }
}); });
unlistenExit = await onSidecarExited(() => {
sidecarAlive = false;
// Mark all running sessions as errored
for (const session of getAgentSessions()) {
if (session.status === 'running' || session.status === 'starting') {
updateAgentStatus(session.id, 'error', 'Sidecar crashed');
}
}
});
} }
function handleAgentEvent(sessionId: string, event: Record<string, unknown>): void { function handleAgentEvent(sessionId: string, event: Record<string, unknown>): void {
@ -76,15 +100,18 @@ function handleAgentEvent(sessionId: string, event: Record<string, unknown>): vo
} }
} }
// Append all messages to the session history
if (messages.length > 0) { if (messages.length > 0) {
appendAgentMessages(sessionId, messages); appendAgentMessages(sessionId, messages);
} }
} }
export function stopAgentDispatcher(): void { export function stopAgentDispatcher(): void {
if (unlistenFn) { if (unlistenMsg) {
unlistenFn(); unlistenMsg();
unlistenFn = null; unlistenMsg = null;
}
if (unlistenExit) {
unlistenExit();
unlistenExit = null;
} }
} }

View file

@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { queryAgent, stopAgent, isAgentReady } from '../../adapters/agent-bridge'; import { queryAgent, stopAgent, isAgentReady, restartAgent } from '../../adapters/agent-bridge';
import { import {
getAgentSession, getAgentSession,
createAgentSession, createAgentSession,
removeAgentSession, removeAgentSession,
type AgentSession, type AgentSession,
} from '../../stores/agents.svelte'; } from '../../stores/agents.svelte';
import { isSidecarAlive, setSidecarAlive } from '../../agent-dispatcher';
import type { import type {
AgentMessage, AgentMessage,
TextContent, TextContent,
@ -30,6 +31,7 @@
let inputPrompt = $state(initialPrompt); let inputPrompt = $state(initialPrompt);
let scrollContainer: HTMLDivElement | undefined = $state(); let scrollContainer: HTMLDivElement | undefined = $state();
let autoScroll = $state(true); let autoScroll = $state(true);
let restarting = $state(false);
onMount(async () => { onMount(async () => {
if (initialPrompt) { if (initialPrompt) {
@ -73,12 +75,31 @@
stopAgent(sessionId).catch(() => {}); stopAgent(sessionId).catch(() => {});
} }
async function handleRestart() {
restarting = true;
try {
await restartAgent();
setSidecarAlive(true);
} catch {
// Still dead
} finally {
restarting = false;
}
}
function scrollToBottom() { function scrollToBottom() {
if (autoScroll && scrollContainer) { if (autoScroll && scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight; scrollContainer.scrollTop = scrollContainer.scrollHeight;
} }
} }
function handleScroll() {
if (!scrollContainer) return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
// Lock auto-scroll if user scrolled up more than 50px from bottom
autoScroll = scrollHeight - scrollTop - clientHeight < 50;
}
// Auto-scroll when new messages arrive // Auto-scroll when new messages arrive
$effect(() => { $effect(() => {
if (session?.messages.length) { if (session?.messages.length) {
@ -121,7 +142,7 @@
</form> </form>
</div> </div>
{:else} {:else}
<div class="messages" bind:this={scrollContainer}> <div class="messages" bind:this={scrollContainer} onscroll={handleScroll}>
{#each session.messages as msg (msg.id)} {#each session.messages as msg (msg.id)}
<div class="message msg-{msg.type}"> <div class="message msg-{msg.type}">
{#if msg.type === 'init'} {#if msg.type === 'init'}
@ -173,6 +194,9 @@
<div class="running-indicator"> <div class="running-indicator">
<span class="pulse"></span> <span class="pulse"></span>
<span>Running...</span> <span>Running...</span>
{#if !autoScroll}
<button class="scroll-btn" onclick={() => { autoScroll = true; scrollToBottom(); }}>Scroll to bottom</button>
{/if}
<button class="stop-btn" onclick={handleStop}>Stop</button> <button class="stop-btn" onclick={handleStop}>Stop</button>
</div> </div>
{:else if session.status === 'done'} {:else if session.status === 'done'}
@ -184,6 +208,11 @@
{:else if session.status === 'error'} {:else if session.status === 'error'}
<div class="error-bar"> <div class="error-bar">
<span>Error: {session.error ?? 'Unknown'}</span> <span>Error: {session.error ?? 'Unknown'}</span>
{#if session.error?.includes('Sidecar') || session.error?.includes('crashed')}
<button class="restart-btn" onclick={handleRestart} disabled={restarting}>
{restarting ? 'Restarting...' : 'Restart Sidecar'}
</button>
{/if}
</div> </div>
{/if} {/if}
</div> </div>
@ -407,11 +436,38 @@
.stop-btn:hover { opacity: 0.9; } .stop-btn:hover { opacity: 0.9; }
.scroll-btn {
background: var(--bg-surface);
border: 1px solid var(--border);
color: var(--text-secondary);
border-radius: 3px;
padding: 2px 8px;
font-size: 10px;
cursor: pointer;
}
.scroll-btn:hover { color: var(--text-primary); }
.restart-btn {
margin-left: auto;
background: var(--ctp-peach);
color: var(--ctp-crust);
border: none;
border-radius: 3px;
padding: 2px 10px;
font-size: 11px;
cursor: pointer;
}
.restart-btn:hover { opacity: 0.9; }
.restart-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.done-bar, .error-bar { .done-bar, .error-bar {
display: flex; display: flex;
gap: 12px; gap: 12px;
font-size: 11px; font-size: 11px;
font-family: var(--font-mono); font-family: var(--font-mono);
align-items: center;
} }
.done-bar { color: var(--ctp-green); } .done-bar { color: var(--ctp-green); }