feat(ui): add compaction indicator and improve event error handling
Compaction indicator: - Add CompactionStatus type and handleCompactionEvent() in app-tool-stream.ts - Show '🧹 Compacting context...' toast while active (with pulse animation) - Show '🧹 Context compacted' briefly after completion - Auto-clear toast after 5 seconds - Add CSS styles for .callout.info, .callout.success, .compaction-indicator Error handling improvements: - Wrap onEvent callback in try/catch in gateway.ts to prevent errors from breaking the WebSocket message handler - Wrap handleGatewayEvent in try/catch with console.error logging to isolate errors and make them visible in devtools These changes address UI freezes during heavy agent activity by: 1. Showing users when compaction is happening 2. Preventing uncaught errors from silently breaking the event loop
This commit is contained in:
parent
e0e39a6d52
commit
8b3e3a4b96
@ -415,6 +415,51 @@
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.callout.info {
|
||||||
|
border-color: rgba(92, 156, 255, 0.4);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout.success {
|
||||||
|
border-color: rgba(92, 255, 128, 0.4);
|
||||||
|
color: var(--positive, #5cff80);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compaction-indicator {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
animation: compaction-fade-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compaction-indicator--active {
|
||||||
|
animation: compaction-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compaction-indicator--complete {
|
||||||
|
animation: compaction-fade-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes compaction-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes compaction-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.code-block {
|
.code-block {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@ -145,6 +145,14 @@ export function connectGateway(host: GatewayHost) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function handleGatewayEvent(host: GatewayHost, evt: GatewayEventFrame) {
|
export function handleGatewayEvent(host: GatewayHost, evt: GatewayEventFrame) {
|
||||||
|
try {
|
||||||
|
handleGatewayEventUnsafe(host, evt);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[gateway] handleGatewayEvent error:", evt.event, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
|
||||||
host.eventLogBuffer = [
|
host.eventLogBuffer = [
|
||||||
{ ts: Date.now(), event: evt.event, payload: evt.payload },
|
{ ts: Date.now(), event: evt.event, payload: evt.payload },
|
||||||
...host.eventLogBuffer,
|
...host.eventLogBuffer,
|
||||||
|
|||||||
@ -444,6 +444,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
showThinking,
|
showThinking,
|
||||||
loading: state.chatLoading,
|
loading: state.chatLoading,
|
||||||
sending: state.chatSending,
|
sending: state.chatSending,
|
||||||
|
compactionStatus: state.compactionStatus,
|
||||||
assistantAvatarUrl: chatAvatarUrl,
|
assistantAvatarUrl: chatAvatarUrl,
|
||||||
messages: state.chatMessages,
|
messages: state.chatMessages,
|
||||||
toolMessages: state.chatToolMessages,
|
toolMessages: state.chatToolMessages,
|
||||||
|
|||||||
@ -138,8 +138,59 @@ export function resetToolStream(host: ToolStreamHost) {
|
|||||||
flushToolStreamSync(host);
|
flushToolStreamSync(host);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CompactionStatus = {
|
||||||
|
active: boolean;
|
||||||
|
startedAt: number | null;
|
||||||
|
completedAt: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CompactionHost = ToolStreamHost & {
|
||||||
|
compactionStatus?: CompactionStatus | null;
|
||||||
|
compactionClearTimer?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMPACTION_TOAST_DURATION_MS = 5000;
|
||||||
|
|
||||||
|
export function handleCompactionEvent(host: CompactionHost, payload: AgentEventPayload) {
|
||||||
|
const data = payload.data ?? {};
|
||||||
|
const phase = typeof data.phase === "string" ? data.phase : "";
|
||||||
|
|
||||||
|
// Clear any existing timer
|
||||||
|
if (host.compactionClearTimer != null) {
|
||||||
|
window.clearTimeout(host.compactionClearTimer);
|
||||||
|
host.compactionClearTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === "start") {
|
||||||
|
host.compactionStatus = {
|
||||||
|
active: true,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
completedAt: null,
|
||||||
|
};
|
||||||
|
} else if (phase === "end") {
|
||||||
|
host.compactionStatus = {
|
||||||
|
active: false,
|
||||||
|
startedAt: host.compactionStatus?.startedAt ?? null,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
};
|
||||||
|
// Auto-clear the toast after duration
|
||||||
|
host.compactionClearTimer = window.setTimeout(() => {
|
||||||
|
host.compactionStatus = null;
|
||||||
|
host.compactionClearTimer = null;
|
||||||
|
}, COMPACTION_TOAST_DURATION_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPayload) {
|
export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPayload) {
|
||||||
if (!payload || payload.stream !== "tool") return;
|
if (!payload) return;
|
||||||
|
|
||||||
|
// Handle compaction events
|
||||||
|
if (payload.stream === "compaction") {
|
||||||
|
handleCompactionEvent(host as CompactionHost, payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.stream !== "tool") return;
|
||||||
const sessionKey =
|
const sessionKey =
|
||||||
typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
|
typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
|
||||||
if (sessionKey && sessionKey !== host.sessionKey) return;
|
if (sessionKey && sessionKey !== host.sessionKey) return;
|
||||||
|
|||||||
@ -125,6 +125,7 @@ export class ClawdbotApp extends LitElement {
|
|||||||
@state() chatStream: string | null = null;
|
@state() chatStream: string | null = null;
|
||||||
@state() chatStreamStartedAt: number | null = null;
|
@state() chatStreamStartedAt: number | null = null;
|
||||||
@state() chatRunId: string | null = null;
|
@state() chatRunId: string | null = null;
|
||||||
|
@state() compactionStatus: import("./app-tool-stream").CompactionStatus | null = null;
|
||||||
@state() chatAvatarUrl: string | null = null;
|
@state() chatAvatarUrl: string | null = null;
|
||||||
@state() chatThinkingLevel: string | null = null;
|
@state() chatThinkingLevel: string | null = null;
|
||||||
@state() chatQueue: ChatQueueItem[] = [];
|
@state() chatQueue: ChatQueueItem[] = [];
|
||||||
|
|||||||
@ -254,7 +254,11 @@ export class GatewayBrowserClient {
|
|||||||
}
|
}
|
||||||
this.lastSeq = seq;
|
this.lastSeq = seq;
|
||||||
}
|
}
|
||||||
this.opts.onEvent?.(evt);
|
try {
|
||||||
|
this.opts.onEvent?.(evt);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[gateway] event handler error:", err);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,12 @@ import {
|
|||||||
import { renderMarkdownSidebar } from "./markdown-sidebar";
|
import { renderMarkdownSidebar } from "./markdown-sidebar";
|
||||||
import "../components/resizable-divider";
|
import "../components/resizable-divider";
|
||||||
|
|
||||||
|
export type CompactionIndicatorStatus = {
|
||||||
|
active: boolean;
|
||||||
|
startedAt: number | null;
|
||||||
|
completedAt: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type ChatProps = {
|
export type ChatProps = {
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
onSessionKeyChange: (next: string) => void;
|
onSessionKeyChange: (next: string) => void;
|
||||||
@ -24,6 +30,7 @@ export type ChatProps = {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
sending: boolean;
|
sending: boolean;
|
||||||
canAbort?: boolean;
|
canAbort?: boolean;
|
||||||
|
compactionStatus?: CompactionIndicatorStatus | null;
|
||||||
messages: unknown[];
|
messages: unknown[];
|
||||||
toolMessages: unknown[];
|
toolMessages: unknown[];
|
||||||
stream: string | null;
|
stream: string | null;
|
||||||
@ -59,6 +66,35 @@ export type ChatProps = {
|
|||||||
onChatScroll?: (event: Event) => void;
|
onChatScroll?: (event: Event) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const COMPACTION_TOAST_DURATION_MS = 5000;
|
||||||
|
|
||||||
|
function renderCompactionIndicator(status: CompactionIndicatorStatus | null | undefined) {
|
||||||
|
if (!status) return nothing;
|
||||||
|
|
||||||
|
// Show "compacting..." while active
|
||||||
|
if (status.active) {
|
||||||
|
return html`
|
||||||
|
<div class="callout info compaction-indicator compaction-indicator--active">
|
||||||
|
🧹 Compacting context...
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show "compaction complete" briefly after completion
|
||||||
|
if (status.completedAt) {
|
||||||
|
const elapsed = Date.now() - status.completedAt;
|
||||||
|
if (elapsed < COMPACTION_TOAST_DURATION_MS) {
|
||||||
|
return html`
|
||||||
|
<div class="callout success compaction-indicator compaction-indicator--complete">
|
||||||
|
🧹 Context compacted
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderChat(props: ChatProps) {
|
export function renderChat(props: ChatProps) {
|
||||||
const canCompose = props.connected;
|
const canCompose = props.connected;
|
||||||
const isBusy = props.sending || props.stream !== null;
|
const isBusy = props.sending || props.stream !== null;
|
||||||
@ -89,6 +125,8 @@ export function renderChat(props: ChatProps) {
|
|||||||
? html`<div class="callout danger">${props.error}</div>`
|
? html`<div class="callout danger">${props.error}</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
|
${renderCompactionIndicator(props.compactionStatus)}
|
||||||
|
|
||||||
${props.focusMode
|
${props.focusMode
|
||||||
? html`
|
? html`
|
||||||
<button
|
<button
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user