feat(compaction): add adaptive chunk sizing, progressive fallback, and UI indicator (#1466)

* fix(ui): allow relative URLs in avatar validation

The isAvatarUrl check only accepted http://, https://, or data: URLs,
but the /avatar/{agentId} endpoint returns relative paths like /avatar/main.
This caused local file avatars to display as text instead of images.

Fixes avatar display for locally configured avatar files.

* fix(gateway): resolve local avatars to URL in HTML injection and RPC

The frontend fix alone wasn't enough because:
1. serveIndexHtml() was injecting the raw avatar filename into HTML
2. agent.identity.get RPC was returning raw filename, overwriting the
   HTML-injected value

Now both paths resolve local file avatars (*.png, *.jpg, etc.) to the
/avatar/{agentId} endpoint URL.

* feat(compaction): add adaptive chunk sizing and progressive fallback

- Add computeAdaptiveChunkRatio() to reduce chunk size for large messages
- Add isOversizedForSummary() to detect messages too large to summarize
- Add summarizeWithFallback() with progressive fallback:
  - Tries full summarization first
  - Falls back to partial summarization excluding oversized messages
  - Notes oversized messages in the summary output
- Add SAFETY_MARGIN (1.2x) buffer for token estimation inaccuracy
- Reduce MIN_CHUNK_RATIO to 0.15 for very large messages

This prevents compaction failures when conversations contain
unusually large tool outputs or responses that exceed the
summarization model's context window.

* 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

* fix(control-ui): add agentId to DEFAULT_ASSISTANT_IDENTITY

TypeScript inferred the union type without agentId when falling back to
DEFAULT_ASSISTANT_IDENTITY, causing build errors at control-ui.ts:222-223.
This commit is contained in:
Dave Lauer
2026-01-23 01:32:30 -05:00
committed by GitHub
parent 68ea6e521b
commit d03c404cb4
11 changed files with 406 additions and 8 deletions

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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[] = [];

View File

@@ -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;
}

View File

@@ -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`
<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) {
const canCompose = props.connected;
const isBusy = props.sending || props.stream !== null;
@@ -89,6 +125,8 @@ export function renderChat(props: ChatProps) {
? html`<div class="callout danger">${props.error}</div>`
: nothing}
${renderCompactionIndicator(props.compactionStatus)}
${props.focusMode
? html`
<button