From e3e9790983532971f8c1cf87813f41bbe9a5dd7c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 28 Jun 2026 09:58:45 +0300 Subject: [PATCH 1/8] fix: restore update command helpers (#1857) Exported package-manager helpers used by openchamber update Added regression coverage for the update-available path --- packages/web/bin/lib/commands-update.test.js | 50 +++++++++++++++++++ packages/web/server/lib/package-manager.js | 4 +- .../web/server/lib/package-manager.test.js | 14 +++++- 3 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 packages/web/bin/lib/commands-update.test.js diff --git a/packages/web/bin/lib/commands-update.test.js b/packages/web/bin/lib/commands-update.test.js new file mode 100644 index 0000000000..f4e11492f6 --- /dev/null +++ b/packages/web/bin/lib/commands-update.test.js @@ -0,0 +1,50 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { describe, expect, it, vi } from 'vitest'; + +import { createUpdateCommand } from './commands-update.js'; + +async function withTempOpenChamberDataDir(fn) { + const previous = process.env.OPENCHAMBER_DATA_DIR; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-update-test-')); + process.env.OPENCHAMBER_DATA_DIR = dir; + try { + return await fn(dir); + } finally { + if (typeof previous === 'string') { + process.env.OPENCHAMBER_DATA_DIR = previous; + } else { + delete process.env.OPENCHAMBER_DATA_DIR; + } + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +describe('update command', () => { + it('uses the package-manager helpers on the update-available path', async () => { + await withTempOpenChamberDataDir(async () => { + const originalWrite = process.stdout.write; + process.stdout.write = vi.fn(() => true); + const executeUpdate = vi.fn(() => ({ success: true, exitCode: 0 })); + const updateCommand = createUpdateCommand({ + packageManagerPath: '/fake/package-manager.js', + serveCommand: vi.fn(), + importFromFilePath: vi.fn(async () => ({ + checkForUpdates: vi.fn(async () => ({ available: true, version: '9.9.9' })), + detectPackageManager: vi.fn(() => 'npm'), + executeUpdate, + getCurrentVersion: vi.fn(() => '1.0.0'), + })), + }); + + try { + await updateCommand({ json: true }); + + expect(executeUpdate).toHaveBeenCalledWith('npm', { silent: true }); + } finally { + process.stdout.write = originalWrite; + } + }); + }); +}); diff --git a/packages/web/server/lib/package-manager.js b/packages/web/server/lib/package-manager.js index fc92af1036..ec482a8e13 100644 --- a/packages/web/server/lib/package-manager.js +++ b/packages/web/server/lib/package-manager.js @@ -483,7 +483,7 @@ export function detectPackageManagerDetails() { }; } -function detectPackageManager() { +export function detectPackageManager() { return detectPackageManagerDetails().packageManager; } @@ -769,7 +769,7 @@ export async function checkForUpdates(options = {}) { /** * Execute the update (used by CLI) */ -function executeUpdate(pm = detectPackageManager(), options = {}) { +export function executeUpdate(pm = detectPackageManager(), options = {}) { const command = getUpdateCommand(pm); if (!options?.silent) { console.log(`Updating ${PACKAGE_NAME} using ${pm}...`); diff --git a/packages/web/server/lib/package-manager.test.js b/packages/web/server/lib/package-manager.test.js index d64bf5410f..a8b962e693 100644 --- a/packages/web/server/lib/package-manager.test.js +++ b/packages/web/server/lib/package-manager.test.js @@ -6,7 +6,12 @@ vi.mock('node:child_process', () => ({ spawnSync: vi.fn(() => ({ status: 0, stdout: '/usr/local/bin', stderr: '' })), })); -const { checkForUpdates, getCurrentVersion } = await import('./package-manager.js'); +const { + checkForUpdates, + detectPackageManager, + executeUpdate, + getCurrentVersion, +} = await import('./package-manager.js'); /** Helper: create a fetch mock that routes by URL pattern */ function createFetchMock() { @@ -251,3 +256,10 @@ describe('getCurrentVersion', () => { expect(getCurrentVersion()).toMatch(/^\d+\.\d+\.\d+|unknown$/); }); }); + +describe('CLI update exports', () => { + it('exports package-manager helpers used by the update command', () => { + expect(typeof detectPackageManager).toBe('function'); + expect(typeof executeUpdate).toBe('function'); + }); +}); From 607dbe5e1c50e69f57b98ddc3fb6bfcef8bbea8c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 28 Jun 2026 12:01:26 +0300 Subject: [PATCH 2/8] fix(chat): stop scroll twitch and message skipping with expanded tools Gate passive auto-follow on active (working/settling) state so idle layout churn from virtualizer re-measurement no longer re-pins the viewport to the bottom. Render default-open tool bodies synchronously on mount so the virtualizer measures the real row height up front instead of growing a frame later and lurching scroll past several messages. --- .../chat/message/parts/ToolPart.tsx | 18 +++- packages/ui/src/hooks/useChatAutoFollow.ts | 89 +++++++++++++++---- 2 files changed, 89 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index d1126352dd..b67a1b0f5f 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -226,14 +226,30 @@ const scheduleDeferredToolBodyMount = (fn: () => void) => { }; const useDeferredExpandedContent = (isExpanded: boolean) => { - const [shouldRender, setShouldRender] = React.useState(false); + // If the tool is expanded when the row first mounts (e.g. "show tools open + // by default", or scrolling a default-open tool back into a virtualized + // view), render the body SYNCHRONOUSLY so the virtualizer measures the real + // height immediately. Deferring it would let the row mount short and grow a + // frame later, which makes the virtualizer compensate scroll and lurch the + // viewport past several messages on slow scroll. Only defer LATER + // user-initiated expansions, where instant single-item feedback isn't worth + // blocking the click on a heavy body render. + const [shouldRender, setShouldRender] = React.useState(isExpanded); + const mountedRef = React.useRef(false); React.useEffect(() => { if (!isExpanded) { + mountedRef.current = true; setShouldRender(false); return; } + if (!mountedRef.current) { + mountedRef.current = true; + setShouldRender(true); + return; + } + return scheduleDeferredToolBodyMount(() => { setShouldRender(true); }); diff --git a/packages/ui/src/hooks/useChatAutoFollow.ts b/packages/ui/src/hooks/useChatAutoFollow.ts index 395bb2f334..f8b543b697 100644 --- a/packages/ui/src/hooks/useChatAutoFollow.ts +++ b/packages/ui/src/hooks/useChatAutoFollow.ts @@ -43,10 +43,15 @@ export interface UseChatAutoFollowResult { } // ────────────────────────────────────────────────────────────────────────── -// This is a direct port of opencode's `createAutoScroll` (SolidJS) to a React -// hook. The model is deliberately simple, which is what makes it flicker-free: +// Chat auto-follow. The model is deliberately simple, which is what makes it +// flicker-free: // -// • Auto-follow is ALWAYS on unless the user scrolled up (`released`). +// • Auto-follow is on unless the user scrolled up (`released`), AND passive +// following only acts while the session is active (working, plus a short +// settle window). When idle, content-size changes are layout churn +// (virtualizer re-measurement, async tool/code rendering) rather than live +// growth, so the hook leaves scroll alone — re-pinning then would fight the +// virtualizer and twitch the viewport. // • Following the bottom is INSTANT — `scrollTop = scrollHeight` inside the // content ResizeObserver, which fires after layout and before paint. There // is NO easing loop and NO settle burst, so there are never two writers @@ -68,9 +73,12 @@ const TOUCH_FINGER_DOWN_THRESHOLD = 2; // How long an "auto" (programmatic) scroll position stays trusted. Browsers can // dispatch the `scroll` event for our write asynchronously, after newer content // has already changed the geometry; the window keeps us from reading that lag as -// a user scroll. Mirrors opencode's 1500ms. +// a user scroll. const AUTO_MARK_TTL_MS = 1500; const AUTO_MATCH_TOLERANCE_PX = 2; +// After streaming stops, keep following the bottom for a short window so the +// final content can settle into place. +const SETTLE_MS = 300; const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now()); @@ -141,11 +149,17 @@ export const useChatAutoFollow = ({ const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false); // `stateRef` is the single source of truth for follow vs released; the React - // state above is a mirror for rendering. `released === userScrolled` in - // opencode terms. + // state above is a mirror for rendering. `released` means the user scrolled + // up and away from the bottom. const stateRef = React.useRef('following'); const isMobileRef = React.useRef(isMobile); isMobileRef.current = isMobile; + const sessionIsWorkingRef = React.useRef(sessionIsWorking); + sessionIsWorkingRef.current = sessionIsWorking; + // `settling` keeps passive follow alive for a short window after work stops + // so the final content can land at the bottom. + const settlingRef = React.useRef(false); + const settleTimerRef = React.useRef | null>(null); const sessionMessageCountRef = React.useRef(sessionMessageCount); sessionMessageCountRef.current = sessionMessageCount; const currentSessionIdRef = React.useRef(currentSessionId); @@ -153,7 +167,7 @@ export const useChatAutoFollow = ({ const lastSessionIdRef = React.useRef(null); - // Programmatic-scroll marker (opencode's `auto`): the bottom position we last + // Programmatic-scroll marker: the bottom position we last // wrote and when. A scroll event whose scrollTop matches `top` within a few // px while still inside the TTL is OUR write, not the user's. const autoRef = React.useRef<{ top: number; time: number } | null>(null); @@ -180,6 +194,17 @@ export const useChatAutoFollow = ({ } }); + // `active` is `working || settling`. Passive auto-follow + // (the ResizeObserver re-pin and any non-forced scrollToBottom) only runs + // while active. When the session is idle, content-size changes are layout + // churn — virtualizer re-measurement, async tool/code rendering — NOT live + // growth, so we must NOT yank the user to the bottom. Forcing this gate is + // what stops the twitch when tall items (expanded tools) re-measure as the + // user scrolls. + const isActive = React.useCallback((): boolean => { + return sessionIsWorkingRef.current || settlingRef.current; + }, []); + const setStateValue = React.useCallback((next: AutoFollowState) => { if (stateRef.current === next) return; stateRef.current = next; @@ -227,7 +252,7 @@ export const useChatAutoFollow = ({ setShowScrollButton(showButton); }, []); - // ── core scroll primitives (ported from opencode) ──────────────────────── + // ── core scroll primitives ─────────────────────────────────────────────── const scrollToBottomNow = React.useCallback((behavior: ScrollBehavior) => { const el = scrollRef.current; if (!el) return; @@ -246,6 +271,10 @@ export const useChatAutoFollow = ({ const scrollToBottom = React.useCallback((force: boolean, behavior: ScrollBehavior = 'auto') => { const el = scrollRef.current; + // Passive follow only while active (working/settling). Forced jumps + // (send, go-to-bottom, session restore) always proceed. + if (!force && !isActive()) return; + if (force && stateRef.current !== 'following') { setStateValue('following'); } @@ -260,7 +289,7 @@ export const useChatAutoFollow = ({ return; } scrollToBottomNow(force ? behavior : 'auto'); - }, [markAuto, scrollToBottomNow, setStateValue]); + }, [isActive, markAuto, scrollToBottomNow, setStateValue]); // User left the bottom — release auto-follow. const stop = React.useCallback(() => { @@ -355,8 +384,8 @@ export const useChatAutoFollow = ({ } pendingInitialRestoreRef.current = null; - // Always return to the bottom on session switch (opencode resumes on the - // same edge). The content ResizeObserver re-pins instantly as late + // Always return to the bottom on session switch. The content + // ResizeObserver re-pins instantly as late // history measures in, so there is no smooth scroll-from-mid artifact. setStateValue('following'); scrollToBottom(true); @@ -379,12 +408,29 @@ export const useChatAutoFollow = ({ } }, [currentSessionId, flushSave]); - // When work begins (a reply starts streaming) and we are still following, - // make sure we are pinned to the bottom. Mirrors opencode's `working` effect. + // When work begins and we are still + // following, pin to the bottom. When work stops, keep following alive for a + // short settle window so the final content lands at the bottom, then go + // idle (after which passive follow is disabled — see `isActive`). React.useEffect(() => { - if (sessionIsWorking && stateRef.current === 'following') { - scrollToBottom(false); + settlingRef.current = false; + if (settleTimerRef.current) { + clearTimeout(settleTimerRef.current); + settleTimerRef.current = null; } + + if (sessionIsWorking) { + if (stateRef.current === 'following') { + scrollToBottom(true); + } + return; + } + + settlingRef.current = true; + settleTimerRef.current = setTimeout(() => { + settlingRef.current = false; + settleTimerRef.current = null; + }, SETTLE_MS); }, [sessionIsWorking, scrollToBottom]); // Suppress the overlay scrollbar thumb only while we are actively following a @@ -405,7 +451,7 @@ export const useChatAutoFollow = ({ } }, [containerEl, currentSessionId, restoreSnapshot]); - // ── scroll event handling (ported from opencode handleScroll) ──────────── + // ── scroll event handling ──────────────────────────────────────────────── const handleScrollEvent = React.useCallback(() => { const el = scrollRef.current; if (!el) return; @@ -524,6 +570,11 @@ export const useChatAutoFollow = ({ return; } updateOverflowAndButton(); + // Idle resize = layout churn (virtualizer re-measurement, async + // tool/code rendering), NOT live growth. Never re-pin when idle, or + // tall items re-measuring as the user scrolls cause an endless + // scroll-to-bottom/re-measure twitch. + if (!isActive()) return; if (stateRef.current !== 'following') return; scrollToBottom(false); }); @@ -533,7 +584,7 @@ export const useChatAutoFollow = ({ observer.observe(inner); } return () => observer.disconnect(); - }, [containerEl, scrollToBottom, setStateValue, updateOverflowAndButton]); + }, [containerEl, isActive, scrollToBottom, setStateValue, updateOverflowAndButton]); React.useEffect(() => { updateOverflowAndButton(); @@ -580,6 +631,10 @@ export const useChatAutoFollow = ({ clearTimeout(autoTimerRef.current); autoTimerRef.current = null; } + if (settleTimerRef.current) { + clearTimeout(settleTimerRef.current); + settleTimerRef.current = null; + } flushSave(); if (saveTimerRef.current !== null) { clearTimeout(saveTimerRef.current); From a5b27c3834a751a351158633c84c507d28f3f4a7 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 28 Jun 2026 12:27:38 +0300 Subject: [PATCH 3/8] fix(chat): improve mobile history loading and virtualization fidelity Give touch surfaces a larger, viewport-relative head start for loading older history so an in-flight fetch completes before the finger reaches the top. Raise the mobile virtualizer overscan so fast flings stay populated instead of leaving blank gaps, and drop the fixed itemSize hint so virtua auto-estimates row heights from measured sizes instead of a flat constant. --- .../ui/src/components/chat/MessageList.tsx | 24 ++++++++----------- .../chat/hooks/useChatTimelineController.ts | 20 +++++++++++++++- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 8957f216ce..653f370d32 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -19,25 +19,22 @@ import type { StreamPhase } from './message/types'; import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useSessionParts } from '@/sync/sync-context'; import type { ReviewTransferDirection } from '@/lib/reviewFlow'; +import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5; const EMPTY_STATIC_ENTRY_MESSAGES: ChatMessageEntry[] = []; const EMPTY_UNGROUPED_MESSAGE_IDS = new Set(); const MESSAGE_LIST_BUFFER_SIZE = 900; +// Touch surfaces fling-scroll natively and dispatch scroll events less often +// than the virtualizer can repaint, so a desktop-sized buffer leaves blank gaps +// during momentum that only fill once measurement catches up. A larger overscan +// keeps more rows mounted around the viewport so fast flings stay populated. +const MOBILE_MESSAGE_LIST_BUFFER_SIZE = 2400; +const resolveMessageListBufferSize = (): number => ( + isMobileSurfaceRuntime() ? MOBILE_MESSAGE_LIST_BUFFER_SIZE : MESSAGE_LIST_BUFFER_SIZE +); const TIMELINE_CACHE_LIMIT = 16; -const estimateHistoryEntryHeight = (entry: RenderEntry | undefined): number => { - if (!entry) { - return 160; - } - - if (entry.kind === 'turn') { - return 180 + Math.min(entry.turn.assistantMessages.length, 4) * 100; - } - - return 140; -}; - const sameKeys = (a: readonly string[] | undefined, b: readonly string[] | undefined): boolean => { if (a === b) return true; if (!a || !b) return false; @@ -982,8 +979,7 @@ const StaticHistoryList = React.memo(({ entries, shouldVirtualize, contentRef, s ref={virtualizerRef} data={entries} cache={virtualCache} - itemSize={virtualCache ? undefined : estimateHistoryEntryHeight(undefined)} - bufferSize={MESSAGE_LIST_BUFFER_SIZE} + bufferSize={resolveMessageListBufferSize()} shift={shift} scrollRef={scrollRef} > diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts index a8e7210610..13a0793cc4 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts @@ -60,6 +60,24 @@ export interface UseChatTimelineControllerResult { const TURN_MODEL_CACHE_MAX = 30 const HISTORY_SCROLL_THRESHOLD = 200 +// On touch surfaces the user can drag continuously toward the top, and +// loadEarlier is an async (network) fetch. A 200px lead is enough on desktop +// (wheel + fast render) but the finger can outrun an in-flight fetch on mobile +// and hit the very top before history lands. Give touch a much larger, +// viewport-relative head start so the fetch completes before the top is +// reached, regardless of how fast the user drags. +const MOBILE_HISTORY_SCROLL_THRESHOLD_MIN = 1200 +const MOBILE_HISTORY_SCROLL_VIEWPORT_FACTOR = 2 + +const resolveHistoryScrollThreshold = (clientHeight: number): number => { + if (!isMobileSurfaceRuntime()) { + return HISTORY_SCROLL_THRESHOLD + } + return Math.max( + MOBILE_HISTORY_SCROLL_THRESHOLD_MIN, + clientHeight * MOBILE_HISTORY_SCROLL_VIEWPORT_FACTOR, + ) +} const VSCODE_TURN_MODEL_CACHE_MAX = 4 const VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES = 30 const MOBILE_TURN_MODEL_CACHE_MAX = 4 @@ -521,7 +539,7 @@ export const useChatTimelineController = ({ const container = scrollRef.current; if (!container) return; if (isPinnedRef.current) return; - if (container.scrollTop >= HISTORY_SCROLL_THRESHOLD) return; + if (container.scrollTop >= resolveHistoryScrollThreshold(container.clientHeight)) return; if (!historySignalsRef.current.canLoadEarlier) return; if (isLoadingOlderRef.current || pendingRevealWorkRef.current) return; From b3f564888b74efcf458b128812b9695d4c47ae22 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 28 Jun 2026 12:33:10 +0300 Subject: [PATCH 4/8] Revert "fix(ui): actually shrink typography classes on mobile viewports (#1791)" This reverts commit 30c82508781a76a89ad09b081d3f271ae69dd21a. --- CHANGELOG.md | 1 - packages/ui/src/styles/mobile.css | 24 ++++++++++++------------ 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76781dd0cd..aed2f0b837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,6 @@ All notable changes to this project will be documented in this file. - Context Panel: chat tabs now use the session title and mark the open chat as seen while you are viewing it. - Desktop/macOS: the Dock icon can now show a badge count for chats with unseen activity, with a new Appearance setting to turn it off. - Context Panel: Browser and Preview tabs no longer accumulate duplicate auth tokens in their URLs after reloads or navigation. -- UI: typography classes (ui-header, ui-label, meta, micro) now actually shrink on mobile viewports — they previously rendered at the same size as desktop despite the mobile clamp rules (thanks to @foundryseven). ## [1.13.5] - 2026-06-27 diff --git a/packages/ui/src/styles/mobile.css b/packages/ui/src/styles/mobile.css index d03f487365..20cb5b9c7f 100644 --- a/packages/ui/src/styles/mobile.css +++ b/packages/ui/src/styles/mobile.css @@ -44,20 +44,20 @@ :root.mobile-pointer:not(.desktop-runtime) { --text-markdown: 1rem; --text-code: 0.875rem; - --text-ui-header: 0.875rem; - --text-ui-label: 0.75rem; - --text-meta: 0.75rem; - --text-micro: 0.6875rem; + --text-ui-header: 0.9375rem; + --text-ui-label: 0.875rem; + --text-meta: 0.875rem; + --text-micro: 0.8125rem; } /* Force override with higher specificity */ :root.mobile-pointer:not(.desktop-runtime) * { --text-markdown: 1rem !important; --text-code: 0.875rem !important; - --text-ui-header: 0.875rem !important; - --text-ui-label: 0.75rem !important; - --text-meta: 0.75rem !important; - --text-micro: 0.6875rem !important; + --text-ui-header: 0.9375rem !important; + --text-ui-label: 0.875rem !important; + --text-meta: 0.875rem !important; + --text-micro: 0.8125rem !important; } /* Additional override for elements using typography classes */ @@ -325,19 +325,19 @@ } .typography-ui-header { - font-size: 0.875rem !important; + font-size: 0.9375rem !important; } .typography-ui-label { - font-size: 0.75rem !important; + font-size: 0.875rem !important; } .typography-meta { - font-size: 0.75rem !important; + font-size: 0.875rem !important; } .typography-micro { - font-size: 0.6875rem !important; + font-size: 0.8125rem !important; } /* Fix font size for tool displays */ From 48a0e1f444bb9222aa3d66e36f522e03ef870bc7 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 28 Jun 2026 13:05:00 +0300 Subject: [PATCH 5/8] fix(providers): keep add-provider flow selected during sync --- packages/ui/src/stores/useConfigStore.test.ts | 66 +++++++++++++++++++ packages/ui/src/stores/useConfigStore.ts | 38 ++++++++--- 2 files changed, 94 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/stores/useConfigStore.test.ts b/packages/ui/src/stores/useConfigStore.test.ts index e587c70330..ca4562c0bb 100644 --- a/packages/ui/src/stores/useConfigStore.test.ts +++ b/packages/ui/src/stores/useConfigStore.test.ts @@ -408,6 +408,31 @@ describe('useConfigStore provider persistence', () => { expect(useConfigStore.getState().selectedProviderId).toBe('__add_provider__'); }); + test('add-provider sentinel is not persisted as a stable provider selection', async () => { + useConfigStore.setState({ + activeDirectoryKey: DIRECTORY, + currentProviderId: 'live', + currentModelId: 'live-model', + selectedProviderId: '__add_provider__', + directoryScoped: { + [DIRECTORY]: { + providers: [provider('live')], + agents: [], + currentProviderId: 'live', + currentModelId: 'live-model', + currentAgentName: undefined, + selectedProviderId: '__add_provider__', + agentModelSelections: {}, + defaultProviders: { default: 'live' }, + }, + }, + }); + + const persisted = JSON.parse(storage.get(STORAGE_KEY) ?? '{}'); + expect(persisted.state.selectedProviderId).toBe(''); + expect(persisted.state.directoryScoped[DIRECTORY].selectedProviderId).toBe(''); + }); + test('setAgent applies settings default variant for an agent configured model', () => { useSessionUIStore.setState({ currentSessionId: 'ses_agent_default_variant' }); useConfigStore.setState({ @@ -603,6 +628,47 @@ describe('useConfigStore provider persistence', () => { expect(state.currentAgentName).toBe('review'); }); + test('sync config defaults do not close the add-provider settings flow', () => { + useConfigStore.setState({ + activeDirectoryKey: DIRECTORY, + providers: [provider('openai', 'gpt-5.5'), provider('anthropic', 'claude')], + agents: [ + testAgent('build', { model: { providerID: 'anthropic', modelID: 'claude' } }), + testAgent('review', { model: { providerID: 'openai', modelID: 'gpt-5.5' } }), + ], + currentProviderId: 'anthropic', + currentModelId: 'claude', + currentAgentName: 'build', + selectedProviderId: '__add_provider__', + selectionSource: 'auto', + directoryScoped: { + [DIRECTORY]: { + providers: [provider('openai', 'gpt-5.5'), provider('anthropic', 'claude')], + agents: [ + testAgent('build', { model: { providerID: 'anthropic', modelID: 'claude' } }), + testAgent('review', { model: { providerID: 'openai', modelID: 'gpt-5.5' } }), + ], + currentProviderId: 'anthropic', + currentModelId: 'claude', + currentAgentName: 'build', + selectedProviderId: '__add_provider__', + agentModelSelections: {}, + defaultProviders: {}, + selectionSource: 'auto', + }, + }, + }); + + emitSyncConfigChanged(DIRECTORY, { default_agent: 'review', model: 'openai/gpt-5.5' }); + + const state = useConfigStore.getState(); + expect(state.currentAgentName).toBe('review'); + expect(state.currentProviderId).toBe('openai'); + expect(state.currentModelId).toBe('gpt-5.5'); + expect(state.selectedProviderId).toBe('__add_provider__'); + expect(state.directoryScoped[DIRECTORY]?.selectedProviderId).toBe('__add_provider__'); + }); + test('duplicate sync config event is a no-op when defaults and selection are unchanged', () => { useConfigStore.setState({ activeDirectoryKey: DIRECTORY, diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 2563b092e7..ae0f3131bc 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -31,7 +31,8 @@ const STT_SILENCE_HOLD_MS_MAX = 10000; const FALLBACK_PROVIDER_ID = "opencode"; const FALLBACK_MODEL_ID = "big-pickle"; // Sentinel selectedProviderId used by the providers UI while the "Add provider" -// form is open. It is intentionally not a real provider id. +// form is open. It is intentionally not a real provider id and must not be +// persisted as a stable provider selection. const ADD_PROVIDER_SENTINEL = "__add_provider__"; const GIT_UTILITY_PROVIDER_ID = "zen"; const GIT_UTILITY_PREFERRED_MODEL_ID = "big-pickle"; @@ -189,6 +190,14 @@ type ProviderWithModelList = Omit & { models: ProviderModel[ type GitModelSelection = { providerId: string; modelId: string }; type ProviderModelSelection = { providerId: string; modelId: string; variant?: string } | null; +const sanitizePersistedSelectedProviderId = (providerId: string | undefined): string => ( + providerId === ADD_PROVIDER_SENTINEL ? "" : (providerId ?? "") +); + +const preserveAddProviderSelection = (currentSelectedProviderId: string | undefined, nextProviderId: string): string => ( + currentSelectedProviderId === ADD_PROVIDER_SENTINEL ? ADD_PROVIDER_SENTINEL : nextProviderId +); + const normalizeOptionalString = (value: unknown): string | undefined => { if (typeof value !== "string") { return undefined; @@ -2424,7 +2433,7 @@ export const useConfigStore = create()( currentProviderId: providerId, currentModelId: modelId, currentVariant: variant, - selectedProviderId: providerId, + selectedProviderId: preserveAddProviderSelection(state.selectedProviderId, providerId), selectionSource: "manual", }; @@ -2432,7 +2441,7 @@ export const useConfigStore = create()( currentProviderId: providerId, currentModelId: modelId, currentVariant: variant, - selectedProviderId: providerId, + selectedProviderId: preserveAddProviderSelection(state.selectedProviderId, providerId), selectionSource: "manual", directoryScoped: { ...state.directoryScoped, @@ -2577,7 +2586,7 @@ export const useConfigStore = create()( currentProviderId: resolvedProviderId, currentModelId: resolvedModelId, currentVariant: resolvedVariant, - selectedProviderId: resolvedProviderId, + selectedProviderId: preserveAddProviderSelection(state.selectedProviderId, resolvedProviderId), } : {}), selectionSource: "auto", @@ -2596,7 +2605,7 @@ export const useConfigStore = create()( nextState.currentProviderId = resolvedProviderId; nextState.currentModelId = resolvedModelId; nextState.currentVariant = resolvedVariant; - nextState.selectedProviderId = resolvedProviderId; + nextState.selectedProviderId = preserveAddProviderSelection(state.selectedProviderId, resolvedProviderId); } return nextState; @@ -2678,6 +2687,7 @@ export const useConfigStore = create()( const currentProviderId = isActive ? state.currentProviderId : baseSnapshot.currentProviderId; const currentModelId = isActive ? state.currentModelId : baseSnapshot.currentModelId; const currentVariant = isActive ? state.currentVariant : baseSnapshot.currentVariant; + const currentSelectedProviderId = isActive ? state.selectedProviderId : baseSnapshot.selectedProviderId; const nextSelection = resolveSelectionWithManualGuard({ agents, providers, @@ -2702,7 +2712,7 @@ export const useConfigStore = create()( currentProviderId: nextSelection.providerId, currentModelId: nextSelection.modelId, currentVariant: nextSelection.variant, - selectedProviderId: nextSelection.providerId, + selectedProviderId: preserveAddProviderSelection(currentSelectedProviderId, nextSelection.providerId), } : {}), selectionSource: nextSelection.selectionSource, @@ -2721,7 +2731,7 @@ export const useConfigStore = create()( state.currentProviderId !== nextSelection.providerId || state.currentModelId !== nextSelection.modelId || state.currentVariant !== nextSelection.variant - || state.selectedProviderId !== nextSelection.providerId + || state.selectedProviderId !== preserveAddProviderSelection(currentSelectedProviderId, nextSelection.providerId) )) )); @@ -2741,7 +2751,7 @@ export const useConfigStore = create()( nextState.currentProviderId = nextSelection.providerId; nextState.currentModelId = nextSelection.modelId; nextState.currentVariant = nextSelection.variant; - nextState.selectedProviderId = nextSelection.providerId; + nextState.selectedProviderId = preserveAddProviderSelection(currentSelectedProviderId, nextSelection.providerId); } } @@ -3288,14 +3298,22 @@ export const useConfigStore = create()( // success) and by the provider/agent config-change subscriptions. partialize: (state) => ({ activeDirectoryKey: state.activeDirectoryKey, - directoryScoped: state.directoryScoped, + directoryScoped: Object.fromEntries( + Object.entries(state.directoryScoped).map(([directoryKey, snapshot]) => [ + directoryKey, + { + ...snapshot, + selectedProviderId: sanitizePersistedSelectedProviderId(snapshot.selectedProviderId), + }, + ]), + ), providers: state.providers, agents: state.agents, currentProviderId: state.currentProviderId, currentModelId: state.currentModelId, currentVariant: state.currentVariant, currentAgentName: state.currentAgentName, - selectedProviderId: state.selectedProviderId, + selectedProviderId: sanitizePersistedSelectedProviderId(state.selectedProviderId), agentModelSelections: state.agentModelSelections, defaultProviders: state.defaultProviders, settingsDefaultModel: state.settingsDefaultModel, From bb5586afce25ce27addcd27a376ecee7859af21a Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 28 Jun 2026 13:33:55 +0300 Subject: [PATCH 6/8] feat(mobile): polish composer model/agent controls and selection overlay Redesign the mobile composer model and agent buttons as borderless, full-bleed labels that hug their content, truncate with an ellipsis when space is tight, and show the provider logo inline before the model name. Tighten the footer action buttons (sessions / attach / auto-accept) so they sit close together, with a small left inset on the group. In the mobile model selection overlay, make the thinking-variant control text-only with a chevron, vertically center the variant and favorite controls in each row, and place the provider logo inline with the model name. --- packages/ui/src/components/chat/ChatInput.tsx | 4 ++-- .../ui/src/components/chat/MobileAgentButton.tsx | 8 +++++--- .../ui/src/components/chat/MobileModelButton.tsx | 13 +++++++++---- .../ui/src/components/chat/ModelControls.tsx | 16 ++++++++-------- packages/ui/src/styles/mobile.css | 10 ++++++++++ 5 files changed, 34 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index abf4bd4c03..91e972c693 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -4514,7 +4514,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo {isMobile ? ( <>
-
+
= ({ onOpenSettings, scrollTo />
-
+
handleOpenMobilePanel('model')} className="min-w-0 flex-shrink" /> = ({ onCycleAge onPointerLeave={handlePointerLeave} onContextMenu={(e) => e.preventDefault()} className={cn( - 'inline-flex min-w-0 items-center select-none', - 'rounded-lg border border-border/50 px-1.5', + 'inline-flex min-w-0 items-stretch select-none', + 'rounded-lg', 'typography-micro font-medium', 'focus:outline-none hover:bg-[var(--interactive-hover)]', 'touch-none', @@ -88,7 +88,9 @@ export const MobileAgentButton: React.FC = ({ onCycleAge }} title={agentLabel} > - {agentLabel} + + {agentLabel} + ); }; diff --git a/packages/ui/src/components/chat/MobileModelButton.tsx b/packages/ui/src/components/chat/MobileModelButton.tsx index b34d45ed88..1094183677 100644 --- a/packages/ui/src/components/chat/MobileModelButton.tsx +++ b/packages/ui/src/components/chat/MobileModelButton.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { cn } from '@/lib/utils'; import { useConfigStore } from '@/stores/useConfigStore'; import { getModelDisplayName } from './mobileControlsUtils'; +import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { useI18n } from '@/lib/i18n'; interface MobileModelButtonProps { @@ -12,6 +13,7 @@ interface MobileModelButtonProps { export const MobileModelButton: React.FC = ({ onOpenModel, className }) => { const { t } = useI18n(); const currentModelId = useConfigStore((state) => state.currentModelId); + const currentProviderId = useConfigStore((state) => state.currentProviderId); const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider); const currentProvider = getCurrentProvider(); const modelLabel = getModelDisplayName(currentProvider, currentModelId, t('chat.modelControls.selectModel')); @@ -21,8 +23,8 @@ export const MobileModelButton: React.FC = ({ onOpenMode type="button" onClick={onOpenModel} className={cn( - 'inline-flex min-w-0 items-center justify-center', - 'rounded-lg border border-border/50 px-1.5', + 'inline-flex min-w-0 items-stretch', + 'rounded-lg', 'typography-micro font-medium text-foreground/80', 'focus:outline-none hover:bg-[var(--interactive-hover)]', className @@ -30,8 +32,11 @@ export const MobileModelButton: React.FC = ({ onOpenMode style={{ height: '26px', maxHeight: '26px', minHeight: '26px' }} title={modelLabel} > - - {modelLabel} + + {currentProviderId ? ( + + ) : null} + {modelLabel} ); diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index 3e25419528..cca3e5ea07 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -1671,7 +1671,7 @@ export const ModelControls: React.FC = ({ isSelected && 'bg-interactive-selection/15 text-interactive-selection-foreground' )} > -
+
) : null} -
+
+
+ +
{/* Identity & Role */}
@@ -810,11 +818,12 @@ export const AgentsPage: React.FC = () => {
+ {/* Row 1: Model + Variant */}
{t('settings.agents.page.field.overrideModel')}
-
+
{ }} />
-
- -
-
+
- {t('settings.agents.page.field.variant')} + {t('settings.agents.page.field.variant')} @@ -843,15 +849,12 @@ export const AgentsPage: React.FC = () => {
- {t('settings.agents.page.field.variantHint')} -
-
{shouldUseVariantSelect ? (