diff --git a/packages/kilo-ui/src/components/diff.tsx b/packages/kilo-ui/src/components/diff.tsx index cee9fba7a4..7b308640e1 100644 --- a/packages/kilo-ui/src/components/diff.tsx +++ b/packages/kilo-ui/src/components/diff.tsx @@ -56,14 +56,21 @@ function enqueue(job: Job) { schedule() } -// When a large batch of diffs becomes near-visible at once, render one diff per -// animation frame. This keeps the UI responsive while preserving expanded state. +const FRAME_BUDGET_MS = 12 + +// When a batch of diffs becomes near-visible at once, render within a per-frame +// time budget so visible diffs populate smoothly in the same frame while preserving +// 60fps responsiveness for large lists. function schedule() { if (frame !== undefined) return frame = requestAnimationFrame(() => { frame = undefined - const job = queue.shift() - if (job && !job.cancelled) job.run() + const deadline = performance.now() + FRAME_BUDGET_MS + while (queue.length > 0) { + const job = queue.shift() + if (job && !job.cancelled) job.run() + if (performance.now() >= deadline) break + } if (queue.length > 0) schedule() }) } diff --git a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts index f94736a960..fe4da953db 100644 --- a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts @@ -589,6 +589,29 @@ describe("Cloud import parts cleanup contract", () => { }) }) +describe("Optimistic parts preservation and smooth status contract", () => { + const source = readFile(SESSION_FILE) + + it("handleMessageCreated preserves optimistic parts instead of deleting them", () => { + const created = extractFunctionBody(source, "handleMessageCreated") + expect(created).not.toMatch(/delete\s+p\[message\.id\]/) + expect(created).toContain("pendingOptimistic.get(message.sessionID)") + }) + + it("handlePartUpdated replaces matching optimistic parts in place", () => { + const updated = extractFunctionBody(source, "handlePartUpdated") + expect(updated).toContain("pendingOptimisticParts.get(effectiveMessageID)") + expect(updated).toContain("optIds.delete") + }) + + it("statusText derives status only from the active turn after the last user message", () => { + const match = source.match(/const statusText = createMemo\(\(\) => \{([\s\S]*?)\n \}\)/) + expect(match).not.toBeNull() + expect(match![1]).toContain('msgs.findLastIndex((m) => m.role === "user")') + expect(match![1]).toContain('language.t("ui.sessionTurn.status.thinking")') + }) +}) + describe("KiloConnectionService pruneSession contract", () => { const source = readFile(CONNECTION_SERVICE_FILE) diff --git a/packages/kilo-vscode/tests/unit/session-queue.test.ts b/packages/kilo-vscode/tests/unit/session-queue.test.ts index 77f8ae6a29..349f7dac07 100644 --- a/packages/kilo-vscode/tests/unit/session-queue.test.ts +++ b/packages/kilo-vscode/tests/unit/session-queue.test.ts @@ -101,11 +101,41 @@ describe("queuedUserMessageIDs", () => { expect(queuedUserMessageIDs(messages, { type: "busy" })).toEqual(["message_3"]) }) - it("returns no queued messages while idle", () => { + it("returns no queued messages while idle without submitting flag", () => { const messages = [user("message_1"), user("message_2")] expect(queuedUserMessageIDs(messages, { type: "idle" })).toEqual([]) }) + + it("queues follow-ups while idle when submitting is true", () => { + const messages = [user("message_1"), user("message_2")] + + expect(queuedUserMessageIDs(messages, { type: "idle" }, undefined, true)).toEqual(["message_2"]) + }) +}) + +describe("activeUserMessageID", () => { + it("returns undefined while idle and not submitting", () => { + const messages = [user("message_1")] + + expect(activeUserMessageID(messages, { type: "idle" })).toBeUndefined() + }) + + it("returns the pending user message while idle when submitting is true", () => { + const messages = [user("message_1")] + + expect(activeUserMessageID(messages, { type: "idle" }, undefined, true)).toBe("message_1") + }) + + it("returns the next pending user message after finished turn when submitting is true", () => { + const messages = [ + user("message_1"), + assistant("message_2", "message_1", { finish: "stop", time: { created: 1, completed: 2 } }), + user("message_3"), + ] + + expect(activeUserMessageID(messages, { type: "idle" }, undefined, true)).toBe("message_3") + }) }) describe("partitionTurns", () => { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx index ae15fa3cf6..ef83c00fcd 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx @@ -132,7 +132,21 @@ export const DiffPanel: Component = (props) => { }) const localComposer = createReviewComposer() const composer = () => props.composer ?? localComposer - const [open, setOpen] = createSignal([]) + const [manualOpen, setManualOpen] = createSignal>({}) + const open = createMemo(() => { + const key = props.sessionKey ?? "" + const diffs = props.diffs + if (diffs.length === 0) return [] + const manual = manualOpen()[key] + if (manual) return sanitizeOpenFiles(diffs, manual) + return initialOpenFiles(diffs) + }) + const setOpen = (files: string[] | ((prev: string[]) => string[])) => { + const key = props.sessionKey ?? "" + const current = open() + const next = typeof files === "function" ? files(current) : files + setManualOpen((prev) => ({ ...prev, [key]: sanitizeOpenFiles(props.diffs, next) })) + } const [draft, setDraft] = createSignal(reviewComposerDraft(composer())) const [editing, setEditing] = createSignal(reviewComposerEdit(composer())) const speechKeys = createMemo(() => { @@ -151,10 +165,6 @@ export const DiffPanel: Component = (props) => { keys: speechKeys, }) let nextId = 0 - // Initialize each worktree with every file expanded, then preserve manual - // collapse state while adding and removing files from live summaries. - let initializedKey: string | undefined - let known = new Set() // Reorder diffs to match the file-tree's depth-first visual order so // scrolling through the accordion matches the tree grouping. @@ -217,43 +227,6 @@ export const DiffPanel: Component = (props) => { focusRoot() } - // Unified open-state effect: tracks both sessionKey and diffs in a single effect - // to eliminate the race condition between the old separate sessionKey-reset and - // diffs-watch effects. Uses the session key to decide when initialization is needed - // vs when we just prune stale entries from the open list. - createEffect( - on( - () => [props.sessionKey, props.diffs] as const, - ([key, diffs]) => { - // No diffs yet (async fetch in progress) — don't mark as initialized - // so auto-open runs when data arrives. - // Important: do not prune on empty, otherwise transient empty updates - // collapse all files and they stay collapsed for the same key. - if (diffs.length === 0) return - - const fileSet = new Set(diffs.map((diff) => diff.file)) - - // New context: initialize open state from the diff policy. - if (key !== initializedKey) { - initializedKey = key - known = fileSet - setOpen(initialOpenFiles(diffs)) - return - } - - // Preserve manual collapse state for known files, while keeping newly - // arriving files expanded when a live summary grows. - const added = diffs.filter((diff) => !known.has(diff.file)).map((diff) => diff.file) - known = fileSet - setOpen((prev) => { - const next = sanitizeOpenFiles(diffs, [...prev.filter((file) => fileSet.has(file)), ...added]) - if (next.length === prev.length && next.every((file, index) => file === prev[index])) return prev - return next - }) - }, - ), - ) - createEffect( on( () => props.sessionKey, diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx index 6f708f8bd9..d35b8f95b0 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx +++ b/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx @@ -134,7 +134,36 @@ export const FullScreenDiffView: Component = (props) => }) const localComposer = createReviewComposer() const composer = () => props.composer ?? localComposer - const [open, setOpen] = createSignal([]) + const [manualOpen, setManualOpen] = createSignal>({}) + const open = createMemo(() => { + const key = props.sessionKey ?? "" + const diffs = props.diffs + if (diffs.length === 0) return [] + const manual = manualOpen()[key] + if (manual) return sanitizeOpenFiles(diffs, manual) + return initialOpenFiles(diffs) + }) + const setOpen = (files: string[] | ((prev: string[]) => string[])) => { + const key = props.sessionKey ?? "" + const current = open() + const next = typeof files === "function" ? files(current) : files + setManualOpen((prev) => ({ ...prev, [key]: sanitizeOpenFiles(props.diffs, next) })) + } + + const [manualActiveFile, setManualActiveFile] = createSignal>({}) + const activeFile = createMemo(() => { + const key = props.sessionKey ?? "" + const diffs = props.diffs + if (diffs.length === 0) return null + const manual = manualActiveFile()[key] + if (manual && diffs.some((d) => d.file === manual)) return manual + return diffs[0]?.file ?? null + }) + const setActiveFile = (file: string | null) => { + const key = props.sessionKey ?? "" + setManualActiveFile((prev) => ({ ...prev, [key]: file })) + } + const [draft, setDraft] = createSignal(reviewComposerDraft(composer())) const [editing, setEditing] = createSignal(reviewComposerEdit(composer())) const speechKeys = createMemo(() => { @@ -152,15 +181,10 @@ export const FullScreenDiffView: Component = (props) => label: t, keys: speechKeys, }) - const [activeFile, setActiveFile] = createSignal(null) const [treeWidth, setTreeWidth] = createSignal(240) let nextId = 0 let draftMeta: AnnotationMeta | null = composer().draft let editMeta: AnnotationMeta | null = composer().edit - // Initialize each worktree with every file expanded, then preserve manual - // collapse state while adding and removing files from live summaries. - let initializedKey: string | undefined - let known = new Set() let rootRef: HTMLDivElement | undefined const [scroller, setScroller] = createSignal() const [virtualizer, setVirtualizer] = createSignal() @@ -214,50 +238,6 @@ export const FullScreenDiffView: Component = (props) => focusRoot() } - // Unified open-state effect: tracks both sessionKey and diffs in a single effect - // to eliminate the race condition between the old separate sessionKey-reset and - // diffs-watch effects. Uses the session key to decide when initialization is needed - // vs when we just prune stale entries from the open list. - createEffect( - on( - () => [props.sessionKey, props.diffs] as const, - ([key, diffs]) => { - if (diffs.length === 0) { - // No diffs yet — clear active file only for a new key; keep current - // selection for transient empty updates in the same key. - if (key !== initializedKey) setActiveFile(null) - return - } - - const fileSet = new Set(diffs.map((diff) => diff.file)) - - // Keep active file in sync — pick first if current is stale - const current = activeFile() - if (!current || !diffs.some((d) => d.file === current)) { - setActiveFile(diffs[0]!.file) - } - - // New context: initialize open state from the diff policy. - if (key !== initializedKey) { - initializedKey = key - known = fileSet - setOpen(initialOpenFiles(diffs)) - return - } - - // Preserve manual collapse state for known files, while keeping newly - // arriving files expanded when a live summary grows. - const added = diffs.filter((diff) => !known.has(diff.file)).map((diff) => diff.file) - known = fileSet - setOpen((prev) => { - const next = sanitizeOpenFiles(diffs, [...prev.filter((file) => fileSet.has(file)), ...added]) - if (next.length === prev.length && next.every((file, index) => file === prev[index])) return prev - return next - }) - }, - ), - ) - createEffect( on( () => props.sessionKey, diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx index e5aa67b906..cd790fda4e 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -179,10 +179,23 @@ export const MessageList: Component = (props) => { const isEmpty = () => turns().length === 0 && !session.loading() && !revert() const activeUserID = createMemo(() => - getActiveUserMessageID(session.messages(), session.statusInfo(), (msg) => session.getParts(msg.id)), + getActiveUserMessageID( + session.messages(), + session.statusInfo(), + (msg) => session.getParts(msg.id), + session.submitting(), + ), ) const queuedIDs = createMemo( - () => new Set(queuedUserMessageIDs(session.messages(), session.statusInfo(), (msg) => session.getParts(msg.id))), + () => + new Set( + queuedUserMessageIDs( + session.messages(), + session.statusInfo(), + (msg) => session.getParts(msg.id), + session.submitting(), + ), + ), ) const rows = createMemo((prev: TranscriptRow[] | undefined) => { const active = activeUserID() diff --git a/packages/kilo-vscode/webview-ui/src/context/session-queue.ts b/packages/kilo-vscode/webview-ui/src/context/session-queue.ts index 59b7833551..61a73eecbe 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-queue.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-queue.ts @@ -198,10 +198,11 @@ export function activeUserMessageID( messages: Message[], status: SessionStatusInfo, parts?: (msg: Message) => Message["parts"], + submitting?: boolean, ) { const id = active(messages, status, parts) if (id) return id - if (status.type === "idle") return undefined + if (status.type === "idle" && !submitting) return undefined return pending(messages, parts) } @@ -209,8 +210,9 @@ export function queuedUserMessageIDs( messages: Message[], status: SessionStatusInfo, parts?: (msg: Message) => Message["parts"], + submitting?: boolean, ) { - if (status.type === "idle") return [] + if (status.type === "idle" && !submitting) return [] const users = messages.filter((msg) => msg.role === "user") const running = active(messages, status, parts) if (running) { diff --git a/packages/kilo-vscode/webview-ui/src/context/session-utils.ts b/packages/kilo-vscode/webview-ui/src/context/session-utils.ts index 218f456026..0ee8ab1578 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-utils.ts @@ -1,5 +1,5 @@ import { reconcile } from "solid-js/store" -import type { Message, Part, ToolPart } from "../types/messages" +import type { Message, MessageLoadMode, Part, ToolPart } from "../types/messages" export const SNAPSHOT_PROGRESS_TEXT = "Initializing snapshot..." @@ -458,3 +458,27 @@ export function collapseCostBreakdown( const aggregated = hidden.reduce((sum, e) => sum + e.cost, 0) return [root, ...visible, { label: summaryLabel(hidden.length), cost: aggregated }] } + +export function mergeMessages(current: Message[], incoming: Message[], mode: Exclude) { + if (mode === "reconcile") { + const byId = new Map() + for (const msg of current) byId.set(msg.id, msg) + for (const msg of incoming) byId.set(msg.id, msg) + return [...byId.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) + } + const seen = new Set() + const source = mode === "prepend" ? [...incoming, ...current] : incoming + return source.filter((msg) => { + if (seen.has(msg.id)) return false + seen.add(msg.id) + return true + }) +} + +export function messageParts(messages: Message[]): Record { + const parts: Record = {} + for (const msg of messages) { + if (msg.parts && msg.parts.length > 0) parts[msg.id] = msg.parts + } + return parts +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 8831d7edfb..1d95663d8d 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -61,6 +61,8 @@ import { buildCostBreakdown, buildSessionToolParts, childID, + mergeMessages, + messageParts, reconcileSessionToolParts, removeSessionToolPart, removeSessionToolPartsForMessage, @@ -473,6 +475,7 @@ export const SessionProvider: ParentComponent = (props) => { // Tracks optimistic messageIDs that haven't been confirmed by the server yet. // Prevents handleMessagesLoaded from wiping them when it replaces the array. const pendingOptimistic = new Map>() + const pendingOptimisticParts = new Map>() // Sessions can be created/imported while an older list request is still in flight. // Keep them until a later list payload confirms them or deletion arrives. const freshSessions = new Set() @@ -1380,28 +1383,6 @@ export const SessionProvider: ParentComponent = (props) => { setPages(sessionID, { ...(pages[sessionID] ?? emptyPageState), ...patch }) } - function mergeMessages(current: Message[], incoming: Message[], mode: Exclude) { - if (mode === "reconcile") { - // Tail reconcile: incoming is the authoritative newest-N snapshot. - // Local state may already hold some of those IDs and may also hold - // newer optimistic entries created after the fetch was taken. Merge - // by id (server wins on collision) then sort by createdAt so new - // server messages land in the right position and optimistic tail - // entries stay at the end. - const byId = new Map() - for (const msg of current) byId.set(msg.id, msg) - for (const msg of incoming) byId.set(msg.id, msg) - return [...byId.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) - } - const seen = new Set() - const source = mode === "prepend" ? [...incoming, ...current] : incoming - return source.filter((msg) => { - if (seen.has(msg.id)) return false - seen.add(msg.id) - return true - }) - } - function recoverPrefs(sessionID: string, messages: Message[], names = agentNames()) { const prefs = resolveMessagePrefs(messages, names) if (prefs.agent && !store.agentSelections[sessionID]) { @@ -1451,14 +1432,6 @@ export const SessionProvider: ParentComponent = (props) => { setTools(sessionID, tools) } - function messageParts(messages: Message[]): Record { - const parts: Record = {} - for (const msg of messages) { - if (msg.parts && msg.parts.length > 0) parts[msg.id] = msg.parts - } - return parts - } - function patchToolPart(sessionID: string | undefined, messageID: string, part: Part) { const sid = sessionID ?? part.sessionID if (!sid) return @@ -1523,6 +1496,7 @@ export const SessionProvider: ParentComponent = (props) => { } for (const msg of messages) { + pendingOptimisticParts.delete(msg.id) const parts = msg.parts ?? [] if (mode === "reconcile" && store.parts[msg.id]) { // Reconcile on a message already hydrated into the reactive store: @@ -1583,22 +1557,9 @@ export const SessionProvider: ParentComponent = (props) => { function handleMessageCreated(message: Message) { if (message.role === "assistant") clearSessionDraftDiscarded(message.sessionID) // Message confirmed by server — no longer optimistic. - // Clear placeholder parts so they don't duplicate alongside real parts - // arriving via individual part.updated events (the server's message.updated - // SSE event does NOT include parts). const pending = pendingOptimistic.get(message.sessionID) - const wasOptimistic = pending?.has(message.id) pending?.delete(message.id) - if (wasOptimistic) { - setStore( - "parts", - produce((p) => { - delete p[message.id] - }), - ) - } - const exists = (store.messages[message.sessionID] ?? []).some((msg) => msg.id === message.id) setStore("messages", message.sessionID, (msgs = []) => { // Check if message already exists (optimistic or update case). @@ -1619,6 +1580,7 @@ export const SessionProvider: ParentComponent = (props) => { if (message.parts && message.parts.length > 0) { stash.remove(message.id) setStore("parts", message.id, message.parts) + pendingOptimisticParts.delete(message.id) } rebuildToolParts(message.sessionID, store.messages[message.sessionID] ?? []) } @@ -1652,6 +1614,8 @@ export const SessionProvider: ParentComponent = (props) => { setStore("parts", effectiveMessageID, stashed) } + const optIds = pendingOptimisticParts.get(effectiveMessageID) + setStore( "parts", produce((parts) => { @@ -1659,11 +1623,12 @@ export const SessionProvider: ParentComponent = (props) => { parts[effectiveMessageID] = [] } - const existingIndex = parts[effectiveMessageID].findIndex((p) => p.id === part.id) + const list = parts[effectiveMessageID] + const existingIndex = list.findIndex((p) => p.id === part.id) if (existingIndex >= 0) { // Update existing part - const existing = parts[effectiveMessageID][existingIndex] + const existing = list[existingIndex] if ( delta?.type === "text-delta" && delta.textDelta && @@ -1680,9 +1645,21 @@ export const SessionProvider: ParentComponent = (props) => { } Object.assign(existing, part) } + } else if (optIds && optIds.size > 0) { + // Server part arrived for a message with optimistic parts: + // replace matching optimistic part in place so the prompt never flickers empty. + const optIdx = list.findIndex((p) => optIds.has(p.id) && p.type === part.type) + if (optIdx >= 0) { + const replaced = list[optIdx] + optIds.delete(replaced.id) + list[optIdx] = part + } else { + list.push(part) + } + if (optIds.size === 0) pendingOptimisticParts.delete(effectiveMessageID) } else { // Add new part - parts[effectiveMessageID].push(part) + list.push(part) } }), ) @@ -1861,6 +1838,7 @@ export const SessionProvider: ParentComponent = (props) => { if (!message.messageID && sid) aborts.clear(sid) if (sid && message.messageID) { pendingOptimistic.get(sid)?.delete(message.messageID) + pendingOptimisticParts.delete(message.messageID) stash.remove(message.messageID) batch(() => { setStore("messages", sid, (msgs = []) => msgs.filter((m) => m.id !== message.messageID)) @@ -2019,7 +1997,10 @@ export const SessionProvider: ParentComponent = (props) => { // Collect message IDs so we can clean up their parts (store + stash) const msgs = store.messages[sessionID] ?? [] const msgIds = msgs.map((m) => m.id) - for (const id of msgIds) stash.remove(id) + for (const id of msgIds) { + stash.remove(id) + pendingOptimisticParts.delete(id) + } clearHiddenErrors(msgIds) setStore( @@ -2238,19 +2219,24 @@ export const SessionProvider: ParentComponent = (props) => { pendingOptimistic.set(sid, pending) const parts: Part[] = [] + const partIds = new Set() if (text) { + const partId = Identifier.ascending("part") + partIds.add(partId) parts.push({ type: "text" as const, - id: Identifier.ascending("part"), + id: partId, messageID, text, metadata: review ? reviewMetadata(review) : undefined, }) } for (const file of files ?? []) { + const partId = Identifier.ascending("part") + partIds.add(partId) parts.push({ type: "file" as const, - id: Identifier.ascending("part"), + id: partId, messageID, mime: file.mime, url: file.url, @@ -2258,6 +2244,7 @@ export const SessionProvider: ParentComponent = (props) => { source: file.source, }) } + pendingOptimisticParts.set(messageID, partIds) setStore("messages", sid, (msgs = []) => [...msgs, temp]) setStore("parts", messageID, parts) @@ -2862,6 +2849,7 @@ export const SessionProvider: ParentComponent = (props) => { function deleteQueuedMessage(sessionID: string, messageID: string) { if (!server.isConnected()) return pendingOptimistic.get(sessionID)?.delete(messageID) + pendingOptimisticParts.delete(messageID) finishSubmission(messageID) vscode.postMessage({ type: "deleteMessage", sessionID, messageID }) } @@ -2915,16 +2903,20 @@ export const SessionProvider: ParentComponent = (props) => { return buildCostBreakdown(id, costs, familyLabels(), language.t("context.stats.thisSession")) }) - // Status text derived from last assistant message parts + // Status text derived from current turn's assistant message parts const statusText = createMemo(() => { if (status() === "idle") return undefined + const thinking = language.t("ui.sessionTurn.status.thinking") const fallback = language.t("ui.sessionTurn.status.consideringNextSteps") const id = currentSessionID() const msgs = messages() - for (let i = msgs.length - 1; i >= 0; i--) { + const lastUserIdx = msgs.findLastIndex((m) => m.role === "user") + if (lastUserIdx < 0) return thinking + + for (let i = msgs.length - 1; i > lastUserIdx; i--) { if (msgs[i].role !== "assistant") continue const parts = getParts(msgs[i].id) - if (parts.length === 0) break + if (parts.length === 0) return thinking const raw = computeStatus(parts[parts.length - 1], language.t) ?? fallback // When delegating to a subagent and that subagent is blocked on a prompt, // replace the generic "Delegating work" label with a more informative one @@ -2937,7 +2929,7 @@ export const SessionProvider: ParentComponent = (props) => { } return raw } - return fallback + return thinking }) const modelUsage = createMemo(() => {