From c51e71c7a2c3058a4e5357195761c41686d96ae3 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sat, 28 Feb 2026 10:31:32 +0000 Subject: [PATCH] perf(ui): memoize changes lists and reduce stream rendering --- .../shell/right-panel/tabs/ChangesTab.tsx | 106 +++++++++------ .../shell/right-panel/tabs/GitChangesTab.tsx | 127 ++++++++++-------- .../ui/src/components/message-block-list.tsx | 2 +- .../styles/messaging/message-block-list.css | 6 + 4 files changed, 141 insertions(+), 100 deletions(-) diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx index a4fcd46a..2062b1c7 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx @@ -1,4 +1,4 @@ -import { For, Show, type Accessor, type Component, type JSX } from "solid-js" +import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js" import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer" @@ -32,14 +32,18 @@ interface ChangesTabProps { } const ChangesTab: Component = (props) => { - const renderContent = (): JSX.Element => { - const sessionId = props.activeSessionId() + const sessionId = createMemo(() => props.activeSessionId()) + const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info")) + const diffs = createMemo(() => (hasSession() ? props.activeSessionDiffs() : null)) - const hasSession = Boolean(sessionId && sessionId !== "info") - const diffs = hasSession ? props.activeSessionDiffs() : null + const sorted = createMemo(() => { + const list = diffs() + if (!Array.isArray(list)) return [] + return [...list].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) + }) - const sorted = Array.isArray(diffs) ? [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) : [] - const totals = sorted.reduce( + const totals = createMemo(() => { + return sorted().reduce( (acc, item) => { acc.additions += typeof item.additions === "number" ? item.additions : 0 acc.deletions += typeof item.deletions === "number" ? item.deletions : 0 @@ -47,41 +51,61 @@ const ChangesTab: Component = (props) => { }, { additions: 0, deletions: 0 }, ) + }) - const mostChanged = sorted.length - ? sorted.reduce((best, item) => { - const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0 - const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0 - const bestScore = bestAdd + bestDel + const mostChanged = createMemo(() => { + const items = sorted() + if (items.length === 0) return null + return items.reduce((best, item) => { + const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0 + const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0 + const bestScore = bestAdd + bestDel - const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0 - const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0 - const score = add + del + const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0 + const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0 + const score = add + del - if (score > bestScore) return item - if (score < bestScore) return best - return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best - }, sorted[0]) - : null + if (score > bestScore) return item + if (score < bestScore) return best + return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best + }, items[0]) + }) - // Auto-select the most-changed file if none selected. + const selectedFileData = createMemo(() => { const currentSelected = props.selectedFile() - const selectedFileData = sorted.find((f) => f.file === currentSelected) || mostChanged - - const scopeKey = `${props.instanceId}:${hasSession ? sessionId : "no-session"}` - - const emptyViewerMessage = () => { - if (!hasSession) return props.t("instanceShell.sessionChanges.noSessionSelected") - if (diffs === undefined) return props.t("instanceShell.sessionChanges.loading") - if (!Array.isArray(diffs) || diffs.length === 0) return props.t("instanceShell.sessionChanges.empty") - return props.t("instanceShell.filesShell.viewerEmpty") + const items = sorted() + if (currentSelected) { + const match = items.find((f) => f.file === currentSelected) + if (match) return match } + return mostChanged() + }) + + const scopeKey = createMemo(() => `${props.instanceId}:${hasSession() ? sessionId() : "no-session"}`) + + const emptyViewerMessage = createMemo(() => { + if (!hasSession()) return props.t("instanceShell.sessionChanges.noSessionSelected") + const currentDiffs = diffs() + if (currentDiffs === undefined) return props.t("instanceShell.sessionChanges.loading") + if (!Array.isArray(currentDiffs) || currentDiffs.length === 0) return props.t("instanceShell.sessionChanges.empty") + return props.t("instanceShell.filesShell.viewerEmpty") + }) + + const headerPath = createMemo(() => { + const file = selectedFileData() + return file?.file ? String(file.file) : props.t("instanceShell.rightPanel.tabs.changes") + }) + + const renderContent = (): JSX.Element => { + const sortedList = sorted() + const totalsValue = totals() + const selected = selectedFileData() const renderViewer = () => (
0 ? selectedFileData : null} + when={selected && hasSession() && sortedList.length > 0 ? selected : null} fallback={
{emptyViewerMessage()} @@ -90,7 +114,7 @@ const ChangesTab: Component = (props) => { > {(file) => ( = (props) => { ) const renderListPanel = () => ( - 0} fallback={renderEmptyList()}> - + 0} fallback={renderEmptyList()}> + {(item) => (
{ props.onSelectFile(item.file, props.isPhoneLayout()) }} @@ -134,11 +158,11 @@ const ChangesTab: Component = (props) => { ) const renderListOverlay = () => ( - 0} fallback={renderEmptyList()}> - + 0} fallback={renderEmptyList()}> + {(item) => (
{ props.onSelectFile(item.file, true) }} @@ -159,8 +183,6 @@ const ChangesTab: Component = (props) => { ) - const headerPath = () => (selectedFileData?.file ? selectedFileData.file : props.t("instanceShell.rightPanel.tabs.changes")) - return ( = (props) => {
- +{totals.additions} + +{totalsValue.additions} - -{totals.deletions} + -{totalsValue.deletions}
diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx index 4575f1e5..b2ab7ff4 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx @@ -1,4 +1,4 @@ -import { For, Show, type Accessor, type Component, type JSX } from "solid-js" +import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js" import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client" import { RefreshCw } from "lucide-solid" @@ -46,17 +46,18 @@ interface GitChangesTabProps { } const GitChangesTab: Component = (props) => { - const renderContent = (): JSX.Element => { - const sessionId = props.activeSessionId() + const sessionId = createMemo(() => props.activeSessionId()) + const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info")) + const entries = createMemo(() => (hasSession() ? props.entries() : null)) - const hasSession = Boolean(sessionId && sessionId !== "info") - const entries = hasSession ? props.entries() : null + const sorted = createMemo(() => { + const list = entries() + if (!Array.isArray(list)) return [] + return [...list].sort((a, b) => String(a.path || "").localeCompare(String(b.path || ""))) + }) - const sorted = Array.isArray(entries) - ? [...entries].sort((a, b) => String(a.path || "").localeCompare(String(b.path || ""))) - : [] - - const totals = sorted.reduce( + const totals = createMemo(() => { + return sorted().reduce( (acc, item) => { acc.additions += typeof item.added === "number" ? item.added : 0 acc.deletions += typeof item.removed === "number" ? item.removed : 0 @@ -64,21 +65,33 @@ const GitChangesTab: Component = (props) => { }, { additions: 0, deletions: 0 }, ) + }) - const nonDeleted = sorted.filter((item) => item && item.status !== "deleted") - - const emptyViewerMessage = () => { - if (!hasSession) return "Select a session to view changes." - if (entries === null) return "Loading git changes…" - if (nonDeleted.length === 0) return "No git changes yet." - return "No file selected." - } + const nonDeleted = createMemo(() => sorted().filter((item) => item && item.status !== "deleted")) + const selectedEntry = createMemo(() => { + const list = sorted() const selectedPath = props.selectedPath() const fallbackPath = props.mostChangedPath() - const selectedEntry = - sorted.find((item) => item.path === selectedPath) || - (fallbackPath ? sorted.find((item) => item.path === fallbackPath) : null) + const found = + list.find((item) => item.path === selectedPath) || + (fallbackPath ? list.find((item) => item.path === fallbackPath) : undefined) + return found ?? null + }) + + const emptyViewerMessage = createMemo(() => { + if (!hasSession()) return "Select a session to view changes." + const currentEntries = entries() + if (currentEntries === null) return "Loading git changes…" + if (nonDeleted().length === 0) return "No git changes yet." + return "No file selected." + }) + + const renderContent = (): JSX.Element => { + const totalsValue = totals() + const selected = selectedEntry() + const sortedList = sorted() + const nonDeletedList = nonDeleted() const renderViewer = () => (
@@ -91,12 +104,12 @@ const GitChangesTab: Component = (props) => { fallback={ = (props) => { } > {(file) => ( - - )} + + )} } > @@ -141,8 +154,8 @@ const GitChangesTab: Component = (props) => { const renderEmptyList = () =>
{emptyViewerMessage()}
const renderListPanel = () => ( - 0} fallback={renderEmptyList()}> - + 0} fallback={renderEmptyList()}> + {(item) => (
= (props) => { ) const renderListOverlay = () => ( - 0} fallback={renderEmptyList()}> - + 0} fallback={renderEmptyList()}> + {(item) => (
= (props) => { ) return ( - - - {selectedEntry?.path || "Git Changes"} - + + + {selected?.path || "Git Changes"} +
- +{totals.additions} + +{totalsValue.additions} - -{totals.deletions} + -{totalsValue.deletions} {(err) => {err()}}
@@ -226,23 +239,23 @@ const GitChangesTab: Component = (props) => { class="files-header-icon-button" title={props.t("instanceShell.rightPanel.actions.refresh")} aria-label={props.t("instanceShell.rightPanel.actions.refresh")} - disabled={!hasSession || props.statusLoading() || entries === null} + disabled={!hasSession() || props.statusLoading() || entries() === null} style={{ "margin-left": "auto" }} onClick={() => props.onRefresh()} > - - - } + + + } list={{ panel: renderListPanel, overlay: renderListOverlay }} viewer={renderViewer()} listOpen={props.listOpen()} diff --git a/packages/ui/src/components/message-block-list.tsx b/packages/ui/src/components/message-block-list.tsx index de5b8890..a0e89134 100644 --- a/packages/ui/src/components/message-block-list.tsx +++ b/packages/ui/src/components/message-block-list.tsx @@ -8,7 +8,7 @@ export function getMessageAnchorId(messageId: string) { return `message-anchor-${messageId}` } -const VIRTUAL_ITEM_MARGIN_PX = 800 +const VIRTUAL_ITEM_MARGIN_PX = 300 interface MessageBlockListProps { instanceId: string diff --git a/packages/ui/src/styles/messaging/message-block-list.css b/packages/ui/src/styles/messaging/message-block-list.css index 81315ab0..c0cd66b6 100644 --- a/packages/ui/src/styles/messaging/message-block-list.css +++ b/packages/ui/src/styles/messaging/message-block-list.css @@ -8,6 +8,12 @@ display: flex; flex-direction: column; gap: 0.0625rem; + + /* Reduce render + paint work for offscreen (but mounted) blocks. + Keep a conservative intrinsic size to avoid scroll jumps. */ + content-visibility: auto; + contain-intrinsic-size: 1px 400px; + contain: layout paint style; } .virtual-item-wrapper {