From 1dc5a26d5d6386d0160617ddc44e82914fca2d8e Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 03:18:40 +0530 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=90=9B=20fix(app):=20prevent=20stream?= =?UTF-8?q?ing=20content=20duplication=20during=20event=20coalescing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When message.part.updated events coalesce within a 16ms batch window (text-end replaces text-start at the same queue index), stale message.part.delta events remained in the queue. On flush, the reducer applied the full text from the coalesced update, then appended the stale deltas on top — doubling the content. Fix: When a message.part.updated event coalesces over an earlier entry, void all stale message.part.delta events for the same messageID:partID already in the queue. Voided indices are skipped during flush. Zero overhead in normal operation (voided set stays empty when no coalescing occurs). Scoped by messageID+partID to avoid affecting unrelated parts. Fixes content repetition visible during LLM streaming in desktop UI. Content displays correctly after app restart (loaded from DB). --- packages/app/src/context/global-sdk.tsx | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index 8c0035d555b7..9fd7e898acf9 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -49,6 +49,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo let queue: Queued[] = [] let buffer: Queued[] = [] const coalesced = new Map() + const voided = new Set() let timer: ReturnType | undefined let last = 0 @@ -61,6 +62,19 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo } } + const voidStaleDeltasForPart = (messageID: string, partID: string) => { + for (let i = 0; i < queue.length; i++) { + const entry = queue[i] + if ( + entry.payload.type === "message.part.delta" && + (entry.payload.properties as { messageID: string; partID: string }).messageID === messageID && + (entry.payload.properties as { messageID: string; partID: string }).partID === partID + ) { + voided.add(i) + } + } + } + const flush = () => { if (timer) clearTimeout(timer) timer = undefined @@ -75,11 +89,13 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo last = Date.now() batch(() => { - for (const event of events) { - emitter.emit(event.directory, event.payload) + for (let i = 0; i < events.length; i++) { + if (voided.has(i)) continue + emitter.emit(events[i].directory, events[i].payload) } }) + voided.clear() buffer.length = 0 } @@ -144,6 +160,10 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo const i = coalesced.get(k) if (i !== undefined) { queue[i] = { directory, payload } + if (payload.type === "message.part.updated") { + const part = (payload.properties as { part: { messageID: string; id: string } }).part + voidStaleDeltasForPart(part.messageID, part.id) + } continue } coalesced.set(k, queue.length) From ae07b098016f593ed9f961826aaff84478bb1802 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 03:54:35 +0530 Subject: [PATCH 2/6] fix(app): connect font size setting to CSS variables and terminal - Add createEffect that applies fontSize to CSS custom properties (--font-size-base, --font-size-small, --font-size-large) - Replace hardcoded fontSize: 14 in terminal with settings value - Add font size stepper control (10-24px) in Settings > Appearance --- .../app/src/components/settings-general.tsx | 30 +++++++++++++++++++ packages/app/src/components/terminal.tsx | 2 +- packages/app/src/context/settings.tsx | 8 +++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 42ee4092f68c..a0e96c616da6 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -267,6 +267,36 @@ export const SettingsGeneral: Component = () => { )} + + +
+ + + {settings.appearance.fontSize()} + + +
+
) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index ce811463fc67..732bb169316d 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -337,7 +337,7 @@ export const Terminal = (props: TerminalProps) => { cursorStyle: "bar", cols: restoreSize?.cols, rows: restoreSize?.rows, - fontSize: 14, + fontSize: settings.appearance.fontSize(), fontFamily: monoFontFamily(settings.appearance.font()), allowTransparency: false, convertEol: false, diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index b43469b5c37c..9b441920c1fc 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -112,6 +112,14 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font)) }) + createEffect(() => { + if (typeof document === "undefined") return + const size = store.appearance?.fontSize ?? defaultSettings.appearance.fontSize + document.documentElement.style.setProperty("--font-size-base", `${size}px`) + document.documentElement.style.setProperty("--font-size-small", `${size - 1}px`) + document.documentElement.style.setProperty("--font-size-large", `${size + 2}px`) + }) + return { ready, get current() { From 62a55a8045372944a63f1ac8c735b3d767e1754f Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 04:30:14 +0530 Subject: [PATCH 3/6] fix(opencode): recover from ContextOverflowError via auto-compaction When the LLM API returns a context overflow error, trigger compaction instead of killing the session. Previously the catch block had a TODO comment and fell through to a fatal error (return 'stop'). Now: sets needsCompaction = true and breaks to post-catch cleanup, which returns 'compact'. The prompt loop then creates a compaction task, generating a summary and allowing the session to continue. --- packages/opencode/src/session/processor.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index e7532d20073b..736d6b75fdf6 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -354,7 +354,11 @@ export namespace SessionProcessor { }) const error = MessageV2.fromError(e, { providerID: input.model.providerID }) if (MessageV2.ContextOverflowError.isInstance(error)) { - // TODO: Handle context overflow error + log.info("context overflow detected, triggering compaction", { + sessionID: input.sessionID, + }) + needsCompaction = true + break } const retry = SessionRetry.retryable(error) if (retry !== undefined) { From 8241109d6aa68f8b77c8e9112576736d1bd4cae2 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 04:30:29 +0530 Subject: [PATCH 4/6] feat(opencode): prune old tool outputs before compaction Add SessionCompaction.prune() calls before both compaction triggers: 1. Proactive overflow check (before LLM call) 2. Reactive compact result (after LLM returns 'compact') Pruning strips old tool outputs first, maximizing context reduction before the compaction summary LLM call. This prevents the summarization itself from hitting context limits. --- packages/opencode/src/session/prompt.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 75bd3c9dfaca..c8b428acdece 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -538,12 +538,13 @@ export namespace SessionPrompt { continue } - // context overflow, needs compaction + // proactive context management: prune + compact before overflow if ( lastFinished && lastFinished.summary !== true && (await SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model })) ) { + await SessionCompaction.prune({ sessionID }) await SessionCompaction.create({ sessionID, agent: lastUser.agent, @@ -702,6 +703,7 @@ export namespace SessionPrompt { if (result === "stop") break if (result === "compact") { + await SessionCompaction.prune({ sessionID }) await SessionCompaction.create({ sessionID, agent: lastUser.agent, From 57818ff54306fd95bbf5c199223db2c63a8d9793 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 04:40:14 +0530 Subject: [PATCH 5/6] feat(app): add context usage card with compact button to context panel Add a prominent context usage card at the top of the session context panel showing: - Large progress circle (32px) with token count and usage % - Color-coded progress bar (green/yellow/red by usage level) - Compact button that calls session.summarize API directly - Button highlights when usage > 70% to encourage compaction --- .../session/session-context-tab.tsx | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 1ea97c395c43..e1efeac71abe 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -1,17 +1,20 @@ -import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" +import { createMemo, createEffect, createSignal, on, onCleanup, For, Show } from "solid-js" import type { JSX } from "solid-js" import { useParams } from "@solidjs/router" import { useSync } from "@/context/sync" +import { useSDK } from "@/context/sdk" import { useLayout } from "@/context/layout" import { checksum } from "@opencode-ai/util/encode" import { findLast } from "@opencode-ai/util/array" import { same } from "@/utils/same" import { Icon } from "@opencode-ai/ui/icon" +import { Button } from "@opencode-ai/ui/button" import { Accordion } from "@opencode-ai/ui/accordion" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { Code } from "@opencode-ai/ui/code" import { Markdown } from "@opencode-ai/ui/markdown" import { ScrollView } from "@opencode-ai/ui/scroll-view" +import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" import { useLanguage } from "@/context/language" import { getSessionContextMetrics } from "./session-context-metrics" @@ -278,6 +281,66 @@ export function SessionContextTab() { onScroll={handleScroll} >
+ + {(c) => { + const sdk = useSDK() + const [compacting, setCompacting] = createSignal(false) + const usage = () => c().usage ?? 0 + const color = () => usage() > 80 ? "var(--syntax-error)" : usage() > 60 ? "var(--syntax-warning)" : "var(--syntax-success)" + + const compact = async () => { + if (!params.id || compacting()) return + setCompacting(true) + try { + const last = visibleUserMessages().at(-1) + await sdk.client.session.summarize({ + sessionID: params.id, + directory: sdk.directory, + providerID: last?.model?.providerID, + modelID: last?.model?.modelID, + }) + } finally { + setCompacting(false) + } + } + + return ( +
+
+
+ +
+ + {formatter().number(c().total)} / {formatter().number(c().limit)} + + + {usage()}% {language.t("context.usage.usage")} + +
+
+ +
+
+
+
+
+ ) + }} + +
{(stat) => [0])} value={stat.value()} />} From 28a3a5305fc361c1934929f17f61bc057654148c Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 04:53:46 +0530 Subject: [PATCH 6/6] feat(app): add wide mode setting to use full window width for chat Add wideMode toggle in Settings > Appearance that disables the centered max-width constraint on the chat timeline. When enabled, chat content expands to fill the full window width instead of being capped at 800px/1000px. --- packages/app/src/components/settings-general.tsx | 12 ++++++++++++ packages/app/src/context/settings.tsx | 6 ++++++ packages/app/src/pages/session.tsx | 4 +++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index a0e96c616da6..2c620b95ef51 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -297,6 +297,18 @@ export const SettingsGeneral: Component = () => {
+ + +
+ settings.appearance.setWideMode(checked)} + /> +
+
) diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 9b441920c1fc..5e4b42fd9f0d 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -32,6 +32,7 @@ export interface Settings { appearance: { fontSize: number font: string + wideMode: boolean } keybinds: Record permissions: { @@ -55,6 +56,7 @@ const defaultSettings: Settings = { appearance: { fontSize: 14, font: "ibm-plex-mono", + wideMode: false, }, keybinds: {}, permissions: { @@ -171,6 +173,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setFont(value: string) { setStore("appearance", "font", value) }, + wideMode: withFallback(() => store.appearance?.wideMode, defaultSettings.appearance.wideMode), + setWideMode(value: boolean) { + setStore("appearance", "wideMode", value) + }, }, keybinds: { get: (action: string) => store.keybinds?.[action], diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index e0ef92682d94..46e6a9e3922a 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -10,6 +10,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks" import { Mark } from "@opencode-ai/ui/logo" import { useSync } from "@/context/sync" +import { useSettings } from "@/context/settings" import { useLayout } from "@/context/layout" import { checksum, base64Encode } from "@opencode-ai/util/encode" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -107,7 +108,8 @@ export default function Page() { if (desktopReviewOpen()) return `${layout.session.width()}px` return `calc(100% - ${layout.fileTree.width()}px)` }) - const centered = createMemo(() => isDesktop() && !desktopSidePanelOpen()) + const settings = useSettings() + const centered = createMemo(() => isDesktop() && !desktopSidePanelOpen() && !settings.appearance.wideMode()) function normalizeTab(tab: string) { if (!tab.startsWith("file://")) return tab