Prevent streaming updates from re-rendering session UI

This commit is contained in:
Shantur Rathore
2025-11-03 20:07:17 +00:00
parent 5cd9bca97f
commit 5ccac400e4
6 changed files with 174 additions and 128 deletions

View File

@@ -1,7 +1,6 @@
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup } from "solid-js" import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
import { instances } from "../stores/instances" import { instances, getInstanceLogs } from "../stores/instances"
import { ChevronDown } from "lucide-solid" import { ChevronDown } from "lucide-solid"
import type { LogEntry } from "../types/instance"
import InstanceInfo from "./instance-info" import InstanceInfo from "./instance-info"
interface InfoViewProps { interface InfoViewProps {
@@ -16,7 +15,7 @@ const InfoView: Component<InfoViewProps> = (props) => {
const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false) const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false)
const instance = () => instances().get(props.instanceId) const instance = () => instances().get(props.instanceId)
const logs = () => instance()?.logs ?? [] const logs = createMemo(() => getInstanceLogs(props.instanceId))
onMount(() => { onMount(() => {
if (scrollRef && savedState) { if (scrollRef && savedState) {

View File

@@ -1,7 +1,6 @@
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup } from "solid-js" import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
import { instances } from "../stores/instances" import { instances, getInstanceLogs } from "../stores/instances"
import { Trash2, ChevronDown } from "lucide-solid" import { ChevronDown } from "lucide-solid"
import type { LogEntry } from "../types/instance"
interface LogsViewProps { interface LogsViewProps {
instanceId: string instanceId: string
@@ -15,7 +14,7 @@ const LogsView: Component<LogsViewProps> = (props) => {
const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false) const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false)
const instance = () => instances().get(props.instanceId) const instance = () => instances().get(props.instanceId)
const logs = () => instance()?.logs ?? [] const logs = createMemo(() => getInstanceLogs(props.instanceId))
onMount(() => { onMount(() => {
if (scrollRef && savedState) { if (scrollRef && savedState) {

View File

@@ -153,6 +153,7 @@ interface MessageCacheEntry {
interface ToolCacheEntry { interface ToolCacheEntry {
toolPart: any toolPart: any
messageInfo?: any messageInfo?: any
signature: string
item: ToolDisplayItem item: ToolDisplayItem
} }
@@ -170,6 +171,13 @@ export default function MessageStream(props: MessageStreamProps) {
const scrollStateKey = () => makeScrollKey(props.instanceId, props.sessionId) const scrollStateKey = () => makeScrollKey(props.instanceId, props.sessionId)
const connectionStatus = () => sseManager.getStatus(props.instanceId) const connectionStatus = () => sseManager.getStatus(props.instanceId)
function createToolSignature(message: Message, toolPart: any, toolIndex: number, messageInfo?: any): string {
const messageId = message.id
const partId = typeof toolPart?.id === "string" ? toolPart.id : `${messageId}-tool-${toolIndex}`
const status = toolPart?.state?.status ?? messageInfo?.state?.status ?? ""
const version = message.version ?? 0
return `${messageId}:${partId}:${status}:${version}`
}
const sessionInfo = createMemo(() => { const sessionInfo = createMemo(() => {
return ( return (
@@ -344,12 +352,14 @@ export default function MessageStream(props: MessageStreamProps) {
const toolPart = displayParts.tool[toolIndex] const toolPart = displayParts.tool[toolIndex]
const toolKey = typeof toolPart?.id === "string" ? toolPart.id : `${message.id}-tool-${toolIndex}` const toolKey = typeof toolPart?.id === "string" ? toolPart.id : `${message.id}-tool-${toolIndex}`
const toolSignature = createToolSignature(message, toolPart, toolIndex, messageInfo)
const toolEntry = toolItemCache.get(toolKey) const toolEntry = toolItemCache.get(toolKey)
if (toolEntry && toolEntry.toolPart === toolPart && toolEntry.messageInfo === messageInfo) { if (toolEntry && toolEntry.signature === toolSignature) {
toolEntry.item.toolPart = toolPart
toolEntry.item.messageInfo = messageInfo
toolEntry.toolPart = toolPart toolEntry.toolPart = toolPart
toolEntry.messageInfo = messageInfo toolEntry.messageInfo = messageInfo
toolEntry.signature = toolSignature
toolEntry.item.toolPart = toolPart
toolEntry.item.messageInfo = messageInfo
newToolCache.set(toolKey, toolEntry) newToolCache.set(toolKey, toolEntry)
items.push(toolEntry.item) items.push(toolEntry.item)
} else { } else {
@@ -359,7 +369,7 @@ export default function MessageStream(props: MessageStreamProps) {
toolPart, toolPart,
messageInfo, messageInfo,
} }
newToolCache.set(toolKey, { toolPart, messageInfo, item: toolItem }) newToolCache.set(toolKey, { toolPart, messageInfo, signature: toolSignature, item: toolItem })
items.push(toolItem) items.push(toolItem)
} }
} }

View File

@@ -4,16 +4,6 @@ import { MessageSquare, Info, Plus, X } from "lucide-solid"
import KeyboardHint from "./keyboard-hint" import KeyboardHint from "./keyboard-hint"
import { keyboardRegistry } from "../lib/keyboard-registry" import { keyboardRegistry } from "../lib/keyboard-registry"
interface SessionListItem {
id: string
title: string
isSpecial?: boolean
isActive: boolean
isParent?: boolean
onSelect: () => void
onClose?: () => void
}
interface SessionListProps { interface SessionListProps {
instanceId: string instanceId: string
sessions: Map<string, Session> sessions: Map<string, Session>
@@ -33,6 +23,24 @@ const MAX_WIDTH = 500
const DEFAULT_WIDTH = 280 const DEFAULT_WIDTH = 280
const STORAGE_KEY = "opencode-session-sidebar-width" const STORAGE_KEY = "opencode-session-sidebar-width"
function arraysEqual(prev: readonly string[] | undefined, next: readonly string[]): boolean {
if (!prev) {
return false
}
if (prev.length !== next.length) {
return false
}
for (let i = 0; i < prev.length; i++) {
if (prev[i] !== next[i]) {
return false
}
}
return true
}
const SessionList: Component<SessionListProps> = (props) => { const SessionList: Component<SessionListProps> = (props) => {
const [sidebarWidth, setSidebarWidth] = createSignal(DEFAULT_WIDTH) const [sidebarWidth, setSidebarWidth] = createSignal(DEFAULT_WIDTH)
const [isResizing, setIsResizing] = createSignal(false) const [isResizing, setIsResizing] = createSignal(false)
@@ -151,44 +159,38 @@ const SessionList: Component<SessionListProps> = (props) => {
removeTouchListeners() removeTouchListeners()
}) })
const sessionSections = createMemo(() => { const parentSessionIds = createMemo(
const parentItems: SessionListItem[] = [] () => {
const childItems: SessionListItem[] = [] const ids: string[] = []
for (const session of props.sessions.values()) {
for (const [id, session] of props.sessions.entries()) { if (session.parentId === null) {
const item: SessionListItem = { ids.push(session.id)
id, }
title: session.title || "Untitled",
isActive: id === props.activeSessionId,
isParent: session.parentId === null,
onSelect: () => props.onSelect(id),
onClose: session.parentId === null ? () => props.onClose(id) : undefined,
} }
ids.push("info")
return ids
},
undefined,
{ equals: arraysEqual },
)
if (session.parentId === null) { const childSessionIds = createMemo(
parentItems.push(item) () => {
} else { const children: { id: string; updated: number }[] = []
childItems.push(item) for (const session of props.sessions.values()) {
if (session.parentId !== null) {
children.push({ id: session.id, updated: session.time.updated ?? 0 })
}
} }
} if (children.length <= 1) {
return children.map((entry) => entry.id)
childItems.sort((a, b) => { }
const sessionA = props.sessions.get(a.id) children.sort((a, b) => b.updated - a.updated)
const sessionB = props.sessions.get(b.id) return children.map((entry) => entry.id)
if (!sessionA || !sessionB) return 0 },
return sessionB.time.updated - sessionA.time.updated undefined,
}) { equals: arraysEqual },
)
parentItems.push({
id: "info",
title: "Info",
isSpecial: true,
isActive: props.activeSessionId === "info",
onSelect: () => props.onSelect("info"),
})
return { parentItems, childItems }
})
return ( return (
<div <div
@@ -221,30 +223,50 @@ const SessionList: Component<SessionListProps> = (props) => {
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide"> <div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
User Session & Info User Session & Info
</div> </div>
<For each={sessionSections().parentItems}> <For each={parentSessionIds()}>
{(item) => ( {(id) => {
<div class="session-list-item group"> if (id === "info") {
<button const isActive = () => props.activeSessionId === "info"
class={`session-item-base ${ return (
item.isActive ? "session-item-active" : "session-item-inactive" <div class="session-list-item group">
} ${item.isSpecial ? "session-item-special" : ""}`} <button
onClick={item.onSelect} class={`session-item-base ${isActive() ? "session-item-active" : "session-item-inactive"} session-item-special`}
title={item.title} onClick={() => props.onSelect("info")}
role="button" title="Info"
aria-selected={item.isActive} role="button"
> aria-selected={isActive()}
<Show when={item.isSpecial} fallback={<MessageSquare class="w-4 h-4 flex-shrink-0" />}> >
<Info class="w-4 h-4 flex-shrink-0" /> <Info class="w-4 h-4 flex-shrink-0" />
</Show> <span class="session-item-title truncate">Info</span>
</button>
</div>
)
}
<span class="session-item-title truncate">{item.title}</span> const session = () => props.sessions.get(id)
if (!session()) {
return null
}
<Show when={!item.isSpecial && item.onClose}> const isActive = () => props.activeSessionId === id
const title = () => session()?.title || "Untitled"
return (
<div class="session-list-item group">
<button
class={`session-item-base ${isActive() ? "session-item-active" : "session-item-inactive"}`}
onClick={() => props.onSelect(id)}
title={title()}
role="button"
aria-selected={isActive()}
>
<MessageSquare class="w-4 h-4 flex-shrink-0" />
<span class="session-item-title truncate">{title()}</span>
<span <span
class="session-item-close opacity-0 group-hover:opacity-100 hover:bg-status-error hover:text-white rounded p-0.5 transition-all" class="session-item-close opacity-0 group-hover:opacity-100 hover:bg-status-error hover:text-white rounded p-0.5 transition-all"
onClick={(event) => { onClick={(event) => {
event.stopPropagation() event.stopPropagation()
item.onClose?.() props.onClose(id)
}} }}
role="button" role="button"
tabIndex={0} tabIndex={0}
@@ -252,51 +274,43 @@ const SessionList: Component<SessionListProps> = (props) => {
> >
<X class="w-3 h-3" /> <X class="w-3 h-3" />
</span> </span>
</Show> </button>
</button> </div>
</div> )
)} }}
</For> </For>
</div> </div>
<Show when={sessionSections().childItems.length > 0}> <Show when={childSessionIds().length > 0}>
<div class="session-section"> <div class="session-section">
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide"> <div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
Agent Sessions Agent Sessions
</div> </div>
<For each={sessionSections().childItems}> <For each={childSessionIds()}>
{(item) => ( {(id) => {
<div class="session-list-item group"> const session = () => props.sessions.get(id)
<button if (!session()) {
class={`session-item-base ${ return null
item.isActive ? "session-item-active" : "session-item-inactive" }
} ${item.isSpecial ? "session-item-special" : ""}`}
onClick={item.onSelect}
title={item.title}
role="button"
aria-selected={item.isActive}
>
<MessageSquare class="w-4 h-4 flex-shrink-0" />
<span class="session-item-title truncate">{item.title}</span> const isActive = () => props.activeSessionId === id
const title = () => session()?.title || "Untitled"
<Show when={!item.isSpecial && item.onClose}> return (
<span <div class="session-list-item group">
class="session-item-close opacity-0 group-hover:opacity-100 hover:bg-status-error hover:text-white rounded p-0.5 transition-all" <button
onClick={(event) => { class={`session-item-base ${isActive() ? "session-item-active" : "session-item-inactive"}`}
event.stopPropagation() onClick={() => props.onSelect(id)}
item.onClose?.() title={title()}
}} role="button"
role="button" aria-selected={isActive()}
tabIndex={0} >
aria-label="Close session" <MessageSquare class="w-4 h-4 flex-shrink-0" />
> <span class="session-item-title truncate">{title()}</span>
<X class="w-3 h-3" /> </button>
</span> </div>
</Show> )
</button> }}
</div>
)}
</For> </For>
</div> </div>
</Show> </Show>

View File

@@ -7,15 +7,43 @@ import { preferences, updateLastUsedBinary } from "./preferences"
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map()) const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null) const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null)
const [instanceLogs, setInstanceLogs] = createSignal<Map<string, LogEntry[]>>(new Map())
const MAX_LOG_ENTRIES = 1000 const MAX_LOG_ENTRIES = 1000
function ensureLogContainer(id: string) {
setInstanceLogs((prev) => {
if (prev.has(id)) {
return prev
}
const next = new Map(prev)
next.set(id, [])
return next
})
}
function removeLogContainer(id: string) {
setInstanceLogs((prev) => {
if (!prev.has(id)) {
return prev
}
const next = new Map(prev)
next.delete(id)
return next
})
}
function getInstanceLogs(instanceId: string): LogEntry[] {
return instanceLogs().get(instanceId) ?? []
}
function addInstance(instance: Instance) { function addInstance(instance: Instance) {
setInstances((prev) => { setInstances((prev) => {
const next = new Map(prev) const next = new Map(prev)
next.set(instance.id, instance) next.set(instance.id, instance)
return next return next
}) })
ensureLogContainer(instance.id)
} }
function updateInstance(id: string, updates: Partial<Instance>) { function updateInstance(id: string, updates: Partial<Instance>) {
@@ -35,6 +63,7 @@ function removeInstance(id: string) {
next.delete(id) next.delete(id)
return next return next
}) })
removeLogContainer(id)
if (activeInstanceId() === id) { if (activeInstanceId() === id) {
setActiveInstanceId(null) setActiveInstanceId(null)
@@ -54,7 +83,6 @@ async function createInstance(folder: string, binaryPath?: string): Promise<stri
pid: 0, pid: 0,
status: "starting", status: "starting",
client: null, client: null,
logs: [],
environmentVariables: preferences().environmentVariables, environmentVariables: preferences().environmentVariables,
} }
@@ -127,27 +155,22 @@ function getActiveInstance(): Instance | null {
} }
function addLog(id: string, entry: LogEntry) { function addLog(id: string, entry: LogEntry) {
setInstances((prev) => { setInstanceLogs((prev) => {
const next = new Map(prev) const next = new Map(prev)
const instance = next.get(id) const existing = next.get(id) ?? []
if (instance) { const updated = existing.length >= MAX_LOG_ENTRIES ? [...existing.slice(1), entry] : [...existing, entry]
const logs = [...instance.logs, entry] next.set(id, updated)
if (logs.length > MAX_LOG_ENTRIES) {
logs.shift()
}
next.set(id, { ...instance, logs })
}
return next return next
}) })
} }
function clearLogs(id: string) { function clearLogs(id: string) {
setInstances((prev) => { setInstanceLogs((prev) => {
const next = new Map(prev) if (!prev.has(id)) {
const instance = next.get(id) return prev
if (instance) {
next.set(id, { ...instance, logs: [] })
} }
const next = new Map(prev)
next.set(id, [])
return next return next
}) })
} }
@@ -164,4 +187,6 @@ export {
getActiveInstance, getActiveInstance,
addLog, addLog,
clearLogs, clearLogs,
instanceLogs,
getInstanceLogs,
} }

View File

@@ -35,7 +35,6 @@ export interface Instance {
status: "starting" | "ready" | "error" | "stopped" status: "starting" | "ready" | "error" | "stopped"
error?: string error?: string
client: OpencodeClient | null client: OpencodeClient | null
logs: LogEntry[]
metadata?: InstanceMetadata metadata?: InstanceMetadata
binaryPath?: string binaryPath?: string
environmentVariables?: Record<string, string> environmentVariables?: Record<string, string>