From d2424a1d057b84988b6f62e4285a66c6e722bb0d Mon Sep 17 00:00:00 2001 From: hsteude Date: Mon, 8 Jun 2026 08:31:54 +0200 Subject: [PATCH 1/7] fix: throttle streaming markdown rendering --- app-prefixable/src/components/markdown.tsx | 48 +++++++++++++++++++--- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/app-prefixable/src/components/markdown.tsx b/app-prefixable/src/components/markdown.tsx index 271fe47a..eababc75 100644 --- a/app-prefixable/src/components/markdown.tsx +++ b/app-prefixable/src/components/markdown.tsx @@ -1,4 +1,4 @@ -import { createMemo } from "solid-js" +import { createEffect, createSignal, onCleanup } from "solid-js" import type { JSX } from "solid-js" import { marked } from "marked" import DOMPurify from "dompurify" @@ -24,6 +24,7 @@ function sanitize(html: string) { } const resetDelay = 2000 +const renderInterval = 120 function escapeHtml(value: string) { return value @@ -99,10 +100,47 @@ interface MarkdownProps { } export function Markdown(props: MarkdownProps) { - const html = createMemo(() => { - if (!props.content) return "" - const raw = marked.parse(props.content, { async: false, renderer }) as string - return sanitize(raw) + const [html, setHtml] = createSignal("") + const state: { + timer: number | undefined + last: number + queued: string + rendered: string + } = { + timer: undefined, + last: 0, + queued: "", + rendered: "", + } + + function render(value: string) { + state.last = Date.now() + state.rendered = value + if (!value) { + setHtml("") + return + } + const raw = marked.parse(value, { async: false, renderer }) as string + setHtml(sanitize(raw)) + } + + function schedule(value: string) { + state.queued = value + if (value === state.rendered) return + if (state.timer !== undefined) return + const elapsed = Date.now() - state.last + const delay = state.last === 0 || elapsed >= renderInterval ? 0 : renderInterval - elapsed + state.timer = window.setTimeout(() => { + state.timer = undefined + render(state.queued) + if (state.queued !== state.rendered) schedule(state.queued) + }, delay) + } + + createEffect(() => schedule(props.content)) + + onCleanup(() => { + if (state.timer !== undefined) clearTimeout(state.timer) }) const onClick: JSX.EventHandler = (event) => { From 62b1273f3d28285000ef652f2650c2a3ea848bac Mon Sep 17 00:00:00 2001 From: hsteude Date: Mon, 8 Jun 2026 08:31:54 +0200 Subject: [PATCH 2/7] perf: avoid timeline regrouping on text deltas --- app-prefixable/src/components/message-timeline.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app-prefixable/src/components/message-timeline.tsx b/app-prefixable/src/components/message-timeline.tsx index d54a4789..3b09b77c 100644 --- a/app-prefixable/src/components/message-timeline.tsx +++ b/app-prefixable/src/components/message-timeline.tsx @@ -71,6 +71,12 @@ function hasVisibleContent(message: DisplayMessage): boolean { return extractTextContent(message.parts).trim().length > 0 } +function timelineStructure(messages: DisplayMessage[]) { + return messages + .map((msg) => `${msg.id}:${msg.role}:${msg.error ? 1 : 0}:${msg.parts.map((p) => p.type).join(",")}`) + .join("|") +} + export function MessageTimeline(props: { messages: DisplayMessage[] processing: boolean @@ -98,9 +104,11 @@ export function MessageTimeline(props: { const [userScrolledUp, setUserScrolledUp] = createSignal(false) // Track previous turn IDs for session switch detection const [prevTurnIds, setPrevTurnIds] = createSignal>(new Set()) + const structure = createMemo(() => timelineStructure(props.messages)) // Convert messages to turns const turns = createMemo(() => { + structure() const filtered = props.messages.filter(hasVisibleContent) return messagesToTurns(filtered) }) From ac52ab9a16339054a3c7b020edc45c53f806d2ba Mon Sep 17 00:00:00 2001 From: hsteude Date: Mon, 8 Jun 2026 08:31:54 +0200 Subject: [PATCH 3/7] fix: consolidate active SSE event handling --- app-prefixable/src/context/events.tsx | 141 ++++-------------- app-prefixable/src/context/sync.tsx | 15 +- app-prefixable/src/pages/directory-layout.tsx | 12 +- 3 files changed, 53 insertions(+), 115 deletions(-) diff --git a/app-prefixable/src/context/events.tsx b/app-prefixable/src/context/events.tsx index 633f5245..58cfa3bc 100644 --- a/app-prefixable/src/context/events.tsx +++ b/app-prefixable/src/context/events.tsx @@ -2,8 +2,7 @@ import { createContext, useContext, createSignal, onCleanup, onMount, type Paren import { createStore, produce } from "solid-js/store" import type { Event, SessionStatus, QuestionRequest } from "../sdk/client" import { useSDK } from "./sdk" -import { useServer } from "./server" -import { createSSEParser, nextSSEReconnectDelay } from "../utils/sse" +import { useSync } from "./sync" type EventHandler = (event: Event) => void @@ -32,28 +31,15 @@ interface EventContextValue { const EventContext = createContext() export function EventProvider(props: ParentProps) { - const { client, directory, url: sdkUrl } = useSDK() - const { authHeaders } = useServer() + const { client, directory } = useSDK() + const sync = useSync() const handlers = new Set() const [status, setStatus] = createStore>({}) const [statusReady, setStatusReady] = createSignal(false) const [pendingQuestions, setPendingQuestions] = createStore>({}) - // Connect to SSE endpoint using fetch (supports custom headers unlike EventSource) - let abortController: AbortController | null = null - let reconnectTimer: ReturnType | null = null - let reconnectDelay = 3000 - - function processEvent(rawData: string) { - try { - const data = JSON.parse(rawData) - // Handle both formats: direct event or wrapped in payload - const event = (data?.payload ?? data) as Event - if (!event || !event.type) { - console.warn("[Events] Received event without type:", data) - return - } - console.log("[Events] Received:", event.type, event.properties) + function processEvent(event: Event) { + if (!event || !event.type) return // Update session status const statusEvent = sessionStatusEvent(event) @@ -84,82 +70,6 @@ export function EventProvider(props: ParentProps) { for (const handler of handlers) { handler(event) } - } catch (err) { - console.error("[Events] Parse error:", err) - } - } - - async function connect() { - if (abortController) return - - const dirParam = directory ? `?directory=${encodeURIComponent(directory)}` : "" - const eventUrl = `${sdkUrl}/event${dirParam}` - console.log("[Events] Connecting to SSE:", eventUrl) - - abortController = new AbortController() - const signal = abortController.signal - - let connectedAt = 0 - - try { - const response = await fetch(eventUrl, { - headers: { ...authHeaders(), Accept: "text/event-stream" }, - signal, - }) - - if (!response.ok || !response.body) { - throw new Error(`SSE connection failed: ${response.status}`) - } - - console.log("[Events] Connected") - connectedAt = Date.now() - - // Clear seen-sets on reconnect so the HTTP re-seed below picks up real status - sseSeenStatuses.clear() - sseAskedQuestions.clear() - sseClearedRequests.clear() - - // Re-seed session statuses from HTTP after reconnect - if (directory) { - client.session.status({ directory }) - .then((res) => { - const statuses = (res.data ?? {}) as Record - for (const [sessionID, s] of Object.entries(statuses)) { - if (!sseSeenStatuses.has(sessionID)) setStatus(sessionID, s) - } - }) - .catch((err) => console.error("[Events] Failed to re-seed statuses:", err)) - } - - const reader = response.body.getReader() - const decoder = new TextDecoder() - const parser = createSSEParser((data) => processEvent(data)) - - while (true) { - const { done, value } = await reader.read() - if (done) break - parser.push(decoder.decode(value, { stream: true })) - } - - // Flush decoder and SSE parser (trailing CR, final event boundary) - parser.push(decoder.decode()) - parser.push("") - - // Stream ended normally, reconnect - throw new Error("SSE stream ended") - } catch (err) { - if (signal.aborted) return // Intentional disconnect - console.error("[Events] Connection error, reconnecting...", err) - abortController = null - reconnectDelay = nextSSEReconnectDelay(connectedAt, Date.now(), reconnectDelay) - - if (!reconnectTimer) { - reconnectTimer = setTimeout(() => { - reconnectTimer = null - connect() - }, reconnectDelay) - } - } } // Connect SSE and seed initial state concurrently. SSE is connected first so @@ -174,8 +84,33 @@ export function EventProvider(props: ParentProps) { const sseClearedRequests = new Set() const sseSeenStatuses = new Set() + function seedStatus() { + if (!directory) { + setStatusReady(true) + return + } + client.session.status({ directory }) + .then((res) => { + const statuses = (res.data ?? {}) as Record + for (const [sessionID, s] of Object.entries(statuses)) { + if (!sseSeenStatuses.has(sessionID)) setStatus(sessionID, s) + } + setStatusReady(true) + }) + .catch((err) => console.error("[Events] Failed to load statuses:", err)) + } + + const unsubSync = sync.subscribe((event) => { + if (event.type === "server.connected") { + sseSeenStatuses.clear() + sseAskedQuestions.clear() + sseClearedRequests.clear() + seedStatus() + } + processEvent(event as Event) + }) + onMount(() => { - connect() if (!directory) { setStatusReady(true) return @@ -192,21 +127,11 @@ export function EventProvider(props: ParentProps) { } }) .catch((err) => console.error("[Events] Failed to load questions:", err)) - client.session.status({ directory }) - .then((res) => { - const statuses = (res.data ?? {}) as Record - for (const [sessionID, s] of Object.entries(statuses)) { - if (!sseSeenStatuses.has(sessionID)) setStatus(sessionID, s) - } - setStatusReady(true) - }) - .catch((err) => console.error("[Events] Failed to load statuses:", err)) + seedStatus() }) onCleanup(() => { - abortController?.abort() - abortController = null - if (reconnectTimer) clearTimeout(reconnectTimer) + unsubSync() }) function subscribe(handler: EventHandler) { diff --git a/app-prefixable/src/context/sync.tsx b/app-prefixable/src/context/sync.tsx index dc286b15..3b55b478 100644 --- a/app-prefixable/src/context/sync.tsx +++ b/app-prefixable/src/context/sync.tsx @@ -11,6 +11,8 @@ type SyncEvent = { properties: Record } +type SyncEventHandler = (event: SyncEvent) => void + export type MessageWithParts = { info: Message parts: Part[] @@ -42,6 +44,7 @@ interface SyncContextValue { parts: (messageID: string) => Part[] providers: () => ProviderData sseUnhealthy: () => boolean + subscribe: (handler: SyncEventHandler) => () => void session: { sync: (sessionID: string) => Promise get: (sessionID: string) => Session | undefined @@ -201,6 +204,7 @@ export function SyncProvider(props: ParentProps) { }) const inflight = new Map>() + const handlers = new Set() const [sseUnhealthy, setSseUnhealthy] = createSignal(false) // Connect to SSE endpoint using fetch (supports custom headers unlike EventSource) @@ -274,7 +278,6 @@ export function SyncProvider(props: ParentProps) { } function handleEvent(event: SyncEvent) { - console.log("[Sync] Event:", event.type) const props = event.properties // Session events @@ -453,6 +456,15 @@ export function SyncProvider(props: ParentProps) { setStore("provider", data) } } + + for (const handler of handlers) { + handler(event) + } + } + + function subscribe(handler: SyncEventHandler) { + handlers.add(handler) + return () => handlers.delete(handler) } async function bootstrap() { @@ -582,6 +594,7 @@ export function SyncProvider(props: ParentProps) { parts: (messageID: string) => store.part[messageID] ?? [], providers: () => store.provider, sseUnhealthy, + subscribe, session: { sync: syncSession, get: (sessionID: string) => { diff --git a/app-prefixable/src/pages/directory-layout.tsx b/app-prefixable/src/pages/directory-layout.tsx index d553564e..1476a6f4 100644 --- a/app-prefixable/src/pages/directory-layout.tsx +++ b/app-prefixable/src/pages/directory-layout.tsx @@ -68,9 +68,9 @@ export function DirectoryLayout(props: ParentProps) { }> {(dir: string) => ( - - - + + + @@ -84,9 +84,9 @@ export function DirectoryLayout(props: ParentProps) { - - - + + + )} From 36faa0915bacce8892db0ca9ec65d0a37c3796bd Mon Sep 17 00:00:00 2001 From: hsteude Date: Mon, 8 Jun 2026 08:58:03 +0200 Subject: [PATCH 4/7] fix: address streaming performance review --- .../src/components/message-timeline.tsx | 89 ++++++++++++------- app-prefixable/src/context/sync.tsx | 6 +- app-prefixable/src/pages/home-layout.tsx | 19 ++-- 3 files changed, 72 insertions(+), 42 deletions(-) diff --git a/app-prefixable/src/components/message-timeline.tsx b/app-prefixable/src/components/message-timeline.tsx index 3b09b77c..a2059c67 100644 --- a/app-prefixable/src/components/message-timeline.tsx +++ b/app-prefixable/src/components/message-timeline.tsx @@ -14,6 +14,12 @@ const TURNS_PER_BATCH = 10 const INITIAL_TURNS = 5 const NEAR_BOTTOM_PX = 10 +type TurnRef = { + id: string + userId: string + assistantIds: string[] +} + // Compute turn-level timing from user and assistant message timestamps function computeTurnTime(user: DisplayMessage, assistants: DisplayMessage[]): Turn["time"] { const started = user.time?.created @@ -29,38 +35,26 @@ function computeTurnTime(user: DisplayMessage, assistants: DisplayMessage[]): Tu return { started, completed, duration } } -// Convert flat message list to turns (user + assistant groupings) -function messagesToTurns(messages: DisplayMessage[]): Turn[] { - const turns: Turn[] = [] - let current: Turn | null = null +function messagesToTurnRefs(messages: DisplayMessage[]): TurnRef[] { + const turns: TurnRef[] = [] + let current: TurnRef | null = null for (const msg of messages) { if (msg.role === "user") { - // Start a new turn - if (current) { - current.time = computeTurnTime(current.userMessage, current.assistantMessages) - turns.push(current) - } + if (current) turns.push(current) current = { id: msg.id, - userMessage: msg, - assistantMessages: [], + userId: msg.id, + assistantIds: [], } } else if (msg.role === "assistant" && current) { - // Add to current turn - current.assistantMessages.push(msg) + current.assistantIds.push(msg.id) } else if (msg.role === "assistant" && !current) { - // Handle assistant messages before first user message console.warn("MessageTimeline: Dropping assistant message before first user message", msg.id) } } - // Don't forget the last turn - if (current) { - current.time = computeTurnTime(current.userMessage, current.assistantMessages) - turns.push(current) - } - + if (current) turns.push(current) return turns } @@ -71,6 +65,12 @@ function hasVisibleContent(message: DisplayMessage): boolean { return extractTextContent(message.parts).trim().length > 0 } +function hasStructuredContent(message: DisplayMessage): boolean { + if (message.error) return true + if (message.role === "user") return true + return message.parts.some((p) => p.type === "tool" || p.type === "text" || p.type === "reasoning") +} + function timelineStructure(messages: DisplayMessage[]) { return messages .map((msg) => `${msg.id}:${msg.role}:${msg.error ? 1 : 0}:${msg.parts.map((p) => p.type).join(",")}`) @@ -106,39 +106,62 @@ export function MessageTimeline(props: { const [prevTurnIds, setPrevTurnIds] = createSignal>(new Set()) const structure = createMemo(() => timelineStructure(props.messages)) - // Convert messages to turns - const turns = createMemo(() => { + const messageById = createMemo(() => { + const map = new Map() + for (const msg of props.messages) map.set(msg.id, msg) + return map + }) + + // Convert messages to turn references only when structure changes. + const turnRefs = createMemo(() => { structure() - const filtered = props.messages.filter(hasVisibleContent) - return messagesToTurns(filtered) + const filtered = untrack(() => props.messages).filter(hasStructuredContent) + return messagesToTurnRefs(filtered) }) + function resolveTurn(ref: TurnRef): Turn | undefined { + const map = messageById() + const user = map.get(ref.userId) + if (!user) return undefined + const assistants = ref.assistantIds.flatMap((id) => map.get(id) ?? []) + return { + id: ref.id, + userMessage: user, + assistantMessages: assistants, + time: computeTurnTime(user, assistants), + } + } + // Calculate which turns to render (from the end, most recent first in render order) - const renderedTurns = createMemo(() => { - const all = turns() + const renderedTurnRefs = createMemo(() => { + const all = turnRefs() const count = Math.min(renderCount(), all.length) - // Take from the end (most recent), but return in chronological order return all.slice(Math.max(0, all.length - count)) }) + const renderedTurns = createMemo(() => { + return renderedTurnRefs().flatMap((ref) => resolveTurn(ref) ?? []) + }) + // Check if there are more turns to load - const hasMore = createMemo(() => renderCount() < turns().length) + const hasMore = createMemo(() => renderCount() < turnRefs().length) // Get the last turn (for showing streaming content) const lastTurn = createMemo(() => { - const all = turns() - return all.length > 0 ? all[all.length - 1] : null + const all = turnRefs() + const ref = all[all.length - 1] + return ref ? resolveTurn(ref) ?? null : null }) // Load more earlier turns with scroll anchoring function loadMore() { if (!containerRef) { - setRenderCount((prev) => Math.min(prev + TURNS_PER_BATCH, turns().length)) + setRenderCount((prev) => Math.min(prev + TURNS_PER_BATCH, turnRefs().length)) return } // Save scroll position relative to bottom before loading const scrollBottom = containerRef.scrollHeight - containerRef.scrollTop - setRenderCount((prev) => Math.min(prev + TURNS_PER_BATCH, turns().length)) + setRenderCount((prev) => Math.min(prev + TURNS_PER_BATCH, turnRefs().length)) // Restore scroll position after DOM update requestAnimationFrame(() => { if (containerRef) { @@ -202,7 +225,7 @@ export function MessageTimeline(props: { // Reset render count when session changes (detect by comparing turn IDs) createEffect(() => { - const currentTurns = turns() + const currentTurns = turnRefs() const currentIds = new Set(currentTurns.map((t) => t.id)) const prevIds = untrack(() => prevTurnIds()) diff --git a/app-prefixable/src/context/sync.tsx b/app-prefixable/src/context/sync.tsx index 3b55b478..0f4024a3 100644 --- a/app-prefixable/src/context/sync.tsx +++ b/app-prefixable/src/context/sync.tsx @@ -458,7 +458,11 @@ export function SyncProvider(props: ParentProps) { } for (const handler of handlers) { - handler(event) + try { + handler(event) + } catch (err) { + console.error("[Sync] Event subscriber failed:", err) + } } } diff --git a/app-prefixable/src/pages/home-layout.tsx b/app-prefixable/src/pages/home-layout.tsx index 497ebf26..9efe7433 100644 --- a/app-prefixable/src/pages/home-layout.tsx +++ b/app-prefixable/src/pages/home-layout.tsx @@ -5,6 +5,7 @@ import { base64Encode } from "../utils/path" import { useServer } from "../context/server" import { SDKProvider } from "../context/sdk" import { EventProvider } from "../context/events" +import { SyncProvider } from "../context/sync" import { ProviderProvider } from "../context/providers" import { MCPProvider } from "../context/mcp" import { ConfigProvider } from "../context/config" @@ -137,10 +138,11 @@ export function HomeLayout(props: ParentProps) { return ( - - - - + + + + +
{/* Project Dialog */}
-
-
-
-
+
+
+
+
+
) } From 323a7063b6864bfd6db40b038835a29befb7c050 Mon Sep 17 00:00:00 2001 From: hsteude Date: Mon, 8 Jun 2026 09:03:51 +0200 Subject: [PATCH 5/7] fix: address markdown timeline review --- app-prefixable/src/components/markdown.tsx | 4 ++++ app-prefixable/src/components/message-timeline.tsx | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app-prefixable/src/components/markdown.tsx b/app-prefixable/src/components/markdown.tsx index eababc75..6d5b528d 100644 --- a/app-prefixable/src/components/markdown.tsx +++ b/app-prefixable/src/components/markdown.tsx @@ -130,6 +130,10 @@ export function Markdown(props: MarkdownProps) { if (state.timer !== undefined) return const elapsed = Date.now() - state.last const delay = state.last === 0 || elapsed >= renderInterval ? 0 : renderInterval - elapsed + if (delay === 0) { + render(value) + return + } state.timer = window.setTimeout(() => { state.timer = undefined render(state.queued) diff --git a/app-prefixable/src/components/message-timeline.tsx b/app-prefixable/src/components/message-timeline.tsx index a2059c67..93f8be44 100644 --- a/app-prefixable/src/components/message-timeline.tsx +++ b/app-prefixable/src/components/message-timeline.tsx @@ -291,7 +291,7 @@ export function MessageTimeline(props: { }} > - Load {Math.min(TURNS_PER_BATCH, turns().length - renderCount())} earlier turns + Load {Math.min(TURNS_PER_BATCH, turnRefs().length - renderCount())} earlier turns @@ -330,7 +330,7 @@ export function MessageTimeline(props: { {/* Empty state */} - +
Date: Mon, 8 Jun 2026 10:07:18 +0200 Subject: [PATCH 6/7] fix: filter empty assistant timeline entries --- app-prefixable/src/components/message-timeline.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app-prefixable/src/components/message-timeline.tsx b/app-prefixable/src/components/message-timeline.tsx index 93f8be44..16c86bad 100644 --- a/app-prefixable/src/components/message-timeline.tsx +++ b/app-prefixable/src/components/message-timeline.tsx @@ -123,7 +123,11 @@ export function MessageTimeline(props: { const map = messageById() const user = map.get(ref.userId) if (!user) return undefined - const assistants = ref.assistantIds.flatMap((id) => map.get(id) ?? []) + const assistants = ref.assistantIds.flatMap((id) => { + const msg = map.get(id) + if (!msg) return [] + return hasVisibleContent(msg) ? [msg] : [] + }) return { id: ref.id, userMessage: user, From 6d96eab0207b23e65aaca2483213356bb786b56d Mon Sep 17 00:00:00 2001 From: hsteude Date: Mon, 8 Jun 2026 10:19:11 +0200 Subject: [PATCH 7/7] fix: avoid duplicate event status seed --- app-prefixable/src/context/events.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app-prefixable/src/context/events.tsx b/app-prefixable/src/context/events.tsx index 58cfa3bc..6fa86378 100644 --- a/app-prefixable/src/context/events.tsx +++ b/app-prefixable/src/context/events.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, createSignal, onCleanup, onMount, type ParentProps } from "solid-js" +import { createContext, useContext, createEffect, createSignal, onCleanup, onMount, type ParentProps } from "solid-js" import { createStore, produce } from "solid-js/store" import type { Event, SessionStatus, QuestionRequest } from "../sdk/client" import { useSDK } from "./sdk" @@ -83,12 +83,15 @@ export function EventProvider(props: ParentProps) { const sseAskedQuestions = new Set() const sseClearedRequests = new Set() const sseSeenStatuses = new Set() + let statusSeeded = false function seedStatus() { if (!directory) { setStatusReady(true) return } + if (statusSeeded) return + statusSeeded = true client.session.status({ directory }) .then((res) => { const statuses = (res.data ?? {}) as Record @@ -100,6 +103,10 @@ export function EventProvider(props: ParentProps) { .catch((err) => console.error("[Events] Failed to load statuses:", err)) } + createEffect(() => { + if (sync.sseUnhealthy()) statusSeeded = false + }) + const unsubSync = sync.subscribe((event) => { if (event.type === "server.connected") { sseSeenStatuses.clear()