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

View File

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

View File

@@ -153,6 +153,7 @@ interface MessageCacheEntry {
interface ToolCacheEntry {
toolPart: any
messageInfo?: any
signature: string
item: ToolDisplayItem
}
@@ -170,6 +171,13 @@ export default function MessageStream(props: MessageStreamProps) {
const scrollStateKey = () => makeScrollKey(props.instanceId, props.sessionId)
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(() => {
return (
@@ -344,12 +352,14 @@ export default function MessageStream(props: MessageStreamProps) {
const toolPart = displayParts.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)
if (toolEntry && toolEntry.toolPart === toolPart && toolEntry.messageInfo === messageInfo) {
toolEntry.item.toolPart = toolPart
toolEntry.item.messageInfo = messageInfo
if (toolEntry && toolEntry.signature === toolSignature) {
toolEntry.toolPart = toolPart
toolEntry.messageInfo = messageInfo
toolEntry.signature = toolSignature
toolEntry.item.toolPart = toolPart
toolEntry.item.messageInfo = messageInfo
newToolCache.set(toolKey, toolEntry)
items.push(toolEntry.item)
} else {
@@ -359,7 +369,7 @@ export default function MessageStream(props: MessageStreamProps) {
toolPart,
messageInfo,
}
newToolCache.set(toolKey, { toolPart, messageInfo, item: toolItem })
newToolCache.set(toolKey, { toolPart, messageInfo, signature: toolSignature, item: toolItem })
items.push(toolItem)
}
}

View File

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