diff --git a/app-prefixable/src/components/markdown.tsx b/app-prefixable/src/components/markdown.tsx index 271fe47a..6d5b528d 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,51 @@ 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 + if (delay === 0) { + render(value) + return + } + 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) => { diff --git a/app-prefixable/src/components/message-timeline.tsx b/app-prefixable/src/components/message-timeline.tsx index d54a4789..16c86bad 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,18 @@ 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(",")}`) + .join("|") +} + export function MessageTimeline(props: { messages: DisplayMessage[] processing: boolean @@ -98,39 +104,68 @@ 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(() => { - const filtered = props.messages.filter(hasVisibleContent) - return messagesToTurns(filtered) + 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 = 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) => { + const msg = map.get(id) + if (!msg) return [] + return hasVisibleContent(msg) ? [msg] : [] + }) + 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) { @@ -194,7 +229,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()) @@ -260,7 +295,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 @@ -299,7 +334,7 @@ export function MessageTimeline(props: { {/* Empty state */} - +
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 @@ -173,9 +83,41 @@ 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 + 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)) + } + + createEffect(() => { + if (sync.sseUnhealthy()) statusSeeded = false + }) + + 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 +134,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..0f4024a3 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,19 @@ export function SyncProvider(props: ParentProps) { setStore("provider", data) } } + + for (const handler of handlers) { + try { + handler(event) + } catch (err) { + console.error("[Sync] Event subscriber failed:", err) + } + } + } + + function subscribe(handler: SyncEventHandler) { + handlers.add(handler) + return () => handlers.delete(handler) } async function bootstrap() { @@ -582,6 +598,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) { - - - + + + )} 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 */}
- - - - + + + + + ) }