diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 6a047fb6c..b46f55b6f 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -415,6 +415,51 @@ 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 { font-family: var(--mono); font-size: 12px; diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index d6b0bbcb5..edabb574f 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -145,6 +145,14 @@ export function connectGateway(host: GatewayHost) { } 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 = [ { ts: Date.now(), event: evt.event, payload: evt.payload }, ...host.eventLogBuffer, diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 96f2b729e..4fa30722f 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -444,6 +444,7 @@ export function renderApp(state: AppViewState) { showThinking, loading: state.chatLoading, sending: state.chatSending, + compactionStatus: state.compactionStatus, assistantAvatarUrl: chatAvatarUrl, messages: state.chatMessages, toolMessages: state.chatToolMessages, diff --git a/ui/src/ui/app-tool-stream.ts b/ui/src/ui/app-tool-stream.ts index b94adada2..5c83c3a79 100644 --- a/ui/src/ui/app-tool-stream.ts +++ b/ui/src/ui/app-tool-stream.ts @@ -138,8 +138,59 @@ export function resetToolStream(host: ToolStreamHost) { 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) { - 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 = typeof payload.sessionKey === "string" ? payload.sessionKey : undefined; if (sessionKey && sessionKey !== host.sessionKey) return; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 94886a393..cd3537f6d 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -125,6 +125,7 @@ export class ClawdbotApp extends LitElement { @state() chatStream: string | null = null; @state() chatStreamStartedAt: number | null = null; @state() chatRunId: string | null = null; + @state() compactionStatus: import("./app-tool-stream").CompactionStatus | null = null; @state() chatAvatarUrl: string | null = null; @state() chatThinkingLevel: string | null = null; @state() chatQueue: ChatQueueItem[] = []; diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index 5f1ab01ff..fc8dde08a 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -254,7 +254,11 @@ export class GatewayBrowserClient { } this.lastSeq = seq; } - this.opts.onEvent?.(evt); + try { + this.opts.onEvent?.(evt); + } catch (err) { + console.error("[gateway] event handler error:", err); + } return; } diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 01b06f22e..97ce9d4ec 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -16,6 +16,12 @@ import { import { renderMarkdownSidebar } from "./markdown-sidebar"; import "../components/resizable-divider"; +export type CompactionIndicatorStatus = { + active: boolean; + startedAt: number | null; + completedAt: number | null; +}; + export type ChatProps = { sessionKey: string; onSessionKeyChange: (next: string) => void; @@ -24,6 +30,7 @@ export type ChatProps = { loading: boolean; sending: boolean; canAbort?: boolean; + compactionStatus?: CompactionIndicatorStatus | null; messages: unknown[]; toolMessages: unknown[]; stream: string | null; @@ -59,6 +66,35 @@ export type ChatProps = { 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` +
+ 🧹 Compacting context... +
+ `; + } + + // Show "compaction complete" briefly after completion + if (status.completedAt) { + const elapsed = Date.now() - status.completedAt; + if (elapsed < COMPACTION_TOAST_DURATION_MS) { + return html` +
+ 🧹 Context compacted +
+ `; + } + } + + return nothing; +} + export function renderChat(props: ChatProps) { const canCompose = props.connected; const isBusy = props.sending || props.stream !== null; @@ -89,6 +125,8 @@ export function renderChat(props: ChatProps) { ? html`
${props.error}
` : nothing} + ${renderCompactionIndicator(props.compactionStatus)} + ${props.focusMode ? html`