diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index e53b7bcd30..0fa829aa1e 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -164,11 +164,10 @@ function RuntimeButtonGroup(props: RuntimeButtonGroupProps) { const state = props.runtimeAvailabilityState; const availabilityMap = state?.status === "loaded" ? state.data : null; - // Hide only during loading (prevents flash), show on failed (allows fallback selection) + // Hide devcontainer only when confirmed missing (not during loading - would cause layout flash) const hideDevcontainer = - state?.status === "loading" || - (availabilityMap?.devcontainer?.available === false && - availabilityMap.devcontainer.reason === "No devcontainer.json found"); + availabilityMap?.devcontainer?.available === false && + availabilityMap.devcontainer.reason === "No devcontainer.json found"; const runtimeOptions = hideDevcontainer ? RUNTIME_OPTIONS.filter((option) => option.value !== RUNTIME_MODE.DEVCONTAINER) @@ -254,6 +253,8 @@ export function CreationControls(props: CreationControlsProps) { const availabilityMap = runtimeAvailabilityState.status === "loaded" ? runtimeAvailabilityState.data : null; const showTrunkBranchSelector = props.branches.length > 0 && runtimeMode !== RUNTIME_MODE.LOCAL; + // Show loading skeleton while branches are loading to avoid layout flash + const showBranchLoadingPlaceholder = !props.branchesLoaded && runtimeMode !== RUNTIME_MODE.LOCAL; // Centralized devcontainer selection logic const devcontainerSelection = resolveDevcontainerSelection({ @@ -493,25 +494,35 @@ export function CreationControls(props: CreationControlsProps) { /> )} - - {/* SSH Host Input - hidden when Coder is enabled */} - {selectedRuntime.mode === "ssh" && !props.coderProps?.enabled && ( + {/* Loading placeholder - reserves space while branches load to avoid layout flash */} + {showBranchLoadingPlaceholder && (
- - onSelectedRuntimeChange({ mode: "ssh", host: e.target.value })} - placeholder="user@host" - disabled={props.disabled} - className={cn( - "bg-bg-dark text-foreground border-border-medium focus:border-accent h-7 w-36 rounded-md border px-2 text-sm focus:outline-none disabled:opacity-50", - props.runtimeFieldError === "ssh" && "border-red-500" - )} - /> + from +
)} + {/* SSH Host Input - hidden when Coder is enabled or will be enabled after checking */} + {selectedRuntime.mode === "ssh" && + !props.coderProps?.enabled && + // Also hide when Coder is still checking but has saved config (will enable after check) + !(props.coderProps?.coderInfo === null && props.coderProps?.coderConfig) && ( +
+ + onSelectedRuntimeChange({ mode: "ssh", host: e.target.value })} + placeholder="user@host" + disabled={props.disabled} + className={cn( + "bg-bg-dark text-foreground border-border-medium focus:border-accent h-7 w-36 rounded-md border px-2 text-sm focus:outline-none disabled:opacity-50", + props.runtimeFieldError === "ssh" && "border-red-500" + )} + /> +
+ )} + {/* Runtime-specific config inputs */} {selectedRuntime.mode === "docker" && ( diff --git a/src/browser/components/ProjectMCPOverview.tsx b/src/browser/components/ProjectMCPOverview.tsx index 0efa1c1f63..6cfe5e6edf 100644 --- a/src/browser/components/ProjectMCPOverview.tsx +++ b/src/browser/components/ProjectMCPOverview.tsx @@ -4,6 +4,8 @@ import type { MCPServerInfo } from "@/common/types/mcp"; import { useAPI } from "@/browser/contexts/API"; import { useSettings } from "@/browser/contexts/SettingsContext"; import { Button } from "@/browser/components/ui/button"; +import { getMCPServersKey } from "@/common/constants/storage"; +import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; interface ProjectMCPOverviewProps { projectPath: string; @@ -16,7 +18,10 @@ export const ProjectMCPOverview: React.FC = (props) => const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); - const [servers, setServers] = React.useState>({}); + // Initialize from localStorage cache to avoid flash + const [servers, setServers] = React.useState>(() => + readPersistedState>(getMCPServersKey(projectPath), {}) + ); React.useEffect(() => { if (!api || settings.isOpen) return; @@ -27,7 +32,10 @@ export const ProjectMCPOverview: React.FC = (props) => .list({ projectPath }) .then((result) => { if (cancelled) return; - setServers(result ?? {}); + const newServers = result ?? {}; + setServers(newServers); + // Cache for next load + updatePersistedState(getMCPServersKey(projectPath), newServers); setError(null); }) .catch((err) => { diff --git a/src/browser/components/ProjectPage.tsx b/src/browser/components/ProjectPage.tsx index 37ce343e72..400313cb01 100644 --- a/src/browser/components/ProjectPage.tsx +++ b/src/browser/components/ProjectPage.tsx @@ -17,10 +17,15 @@ import { ConfigureProvidersPrompt } from "./ConfigureProvidersPrompt"; import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; import type { ProvidersConfigMap } from "@/common/orpc/types"; import { AgentsInitBanner } from "./AgentsInitBanner"; -import { usePersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { + usePersistedState, + updatePersistedState, + readPersistedState, +} from "@/browser/hooks/usePersistedState"; import { getAgentIdKey, getAgentsInitNudgeKey, + getArchivedWorkspacesKey, getInputKey, getPendingScopeId, getProjectScopeId, @@ -72,7 +77,10 @@ export const ProjectPage: React.FC = ({ const { api } = useAPI(); const chatInputRef = useRef(null); const pendingAgentsInitSendRef = useRef(false); - const [archivedWorkspaces, setArchivedWorkspaces] = useState([]); + // Initialize from localStorage cache to avoid flash when archived workspaces appear + const [archivedWorkspaces, setArchivedWorkspaces] = useState(() => + readPersistedState(getArchivedWorkspacesKey(projectPath), []) + ); const [showAgentsInitNudge, setShowAgentsInitNudge] = usePersistedState( getAgentsInitNudgeKey(projectPath), false, @@ -128,8 +136,13 @@ export const ProjectPage: React.FC = ({ const syncArchivedState = useCallback(() => { const next = Array.from(archivedMapRef.current.values()); - setArchivedWorkspaces((prev) => (archivedListsEqual(prev, next) ? prev : next)); - }, []); + setArchivedWorkspaces((prev) => { + if (archivedListsEqual(prev, next)) return prev; + // Persist to localStorage for optimistic cache on next load + updatePersistedState(getArchivedWorkspacesKey(projectPath), next); + return next; + }); + }, [projectPath]); // Fetch archived workspaces for this project on mount useEffect(() => { @@ -289,8 +302,16 @@ export const ProjectPage: React.FC = ({ /> )} {/* Configured providers bar - compact icon carousel */} - {providersConfig && hasProviders && ( - + {providersLoading ? ( + // Skeleton placeholder matching ConfiguredProvidersBar height +
+
+
+ ) : ( + hasProviders && + providersConfig && ( + + ) )} {/* ChatInput for workspace creation - includes section selector */} (null); useEffect(() => { const prevMode = prevSelectedRuntimeModeRef.current; - if (prevMode !== selectedRuntime.mode) { + if (prevMode !== null && prevMode !== selectedRuntime.mode) { if (selectedRuntime.mode === RUNTIME_MODE.SSH) { const needsHostRestore = !selectedRuntime.host.trim() && lastSshHost.trim(); const needsCoderRestore = diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index d1753675ed..b4e2a3f123 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -73,6 +73,24 @@ export function getMCPTestResultsKey(projectPath: string): string { return `mcpTestResults:${projectPath}`; } +/** + * Get the localStorage key for cached archived workspaces per project + * Format: "archivedWorkspaces:{projectPath}" + * Stores: Array of workspace metadata objects (optimistic cache) + */ +export function getArchivedWorkspacesKey(projectPath: string): string { + return `archivedWorkspaces:${projectPath}`; +} + +/** + * Get the localStorage key for cached MCP servers per project + * Format: "mcpServers:{projectPath}" + * Stores: Record (optimistic cache) + */ +export function getMCPServersKey(projectPath: string): string { + return `mcpServers:${projectPath}`; +} + /** * Get the localStorage key for thinking level preference per scope (workspace/project). * Format: "thinkingLevel:{scopeId}"